diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a8de4241..7407bf2a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,17 @@ name: CI on: push: - branches: ["*"] + branches: ["**"] pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint-build-test: - runs-on: macos-latest + runs-on: macos-15-intel + timeout-minutes: 70 steps: - uses: actions/checkout@v6 @@ -36,9 +41,12 @@ jobs: run: ./Scripts/lint.sh lint - name: Swift Test - run: swift test --no-parallel + timeout-minutes: 60 + run: | + python3 Scripts/ci_swift_test_by_suite.py --group-size 1 --timeout 120 build-linux-cli: + timeout-minutes: 20 strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 8c6309129..c87008fdb 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -1,34 +1,76 @@ -name: Release Linux CLI +name: Release CLI on: release: types: [published] workflow_dispatch: + inputs: + tag: + description: "Release tag to upload assets to (for example, v0.24)." + required: false + type: string permissions: contents: write jobs: - build-linux-cli: + build-cli: strategy: fail-fast: false matrix: include: - name: linux-x64 runs-on: ubuntu-24.04 + platform: linux + asset-arch: x86_64 + build-arch: "" + static-swift-stdlib: true - name: linux-arm64 runs-on: ubuntu-24.04-arm + platform: linux + asset-arch: aarch64 + build-arch: "" + static-swift-stdlib: true + - name: macos-arm64 + runs-on: macos-15 + platform: macos + asset-arch: arm64 + build-arch: arm64 + static-swift-stdlib: false + - name: macos-x86_64 + runs-on: macos-15-intel + platform: macos + asset-arch: x86_64 + build-arch: x86_64 + static-swift-stdlib: false runs-on: ${{ matrix.runs-on }} + env: + RELEASE_TAG: ${{ inputs.tag || github.ref_name }} steps: - uses: actions/checkout@v6 + - name: Select Xcode 26.1.1 (if present) or fallback to default + if: matrix.platform == 'macos' + run: | + set -euo pipefail + for candidate in /Applications/Xcode_26.1.1.app /Applications/Xcode_26.1.app /Applications/Xcode.app; do + if [[ -d "$candidate" ]]; then + sudo xcode-select -s "${candidate}/Contents/Developer" + echo "DEVELOPER_DIR=${candidate}/Contents/Developer" >> "$GITHUB_ENV" + break + fi + done + /usr/bin/xcodebuild -version + - name: Runner info run: | set -euo pipefail uname -a uname -m + swift --version - name: Install Swift 6.2.1 via swiftly + if: matrix.platform == 'linux' shell: bash run: | set -euo pipefail @@ -63,7 +105,80 @@ jobs: swift --version - name: Build CodexBarCLI (release) - run: swift build -c release --product CodexBarCLI --static-swift-stdlib + id: build + shell: bash + run: | + set -euo pipefail + + BUILD_ARGS=(swift build -c release --product CodexBarCLI) + if [[ -n "${{ matrix.build-arch }}" ]]; then + BUILD_ARGS+=(--arch "${{ matrix.build-arch }}") + fi + if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then + BUILD_ARGS+=(--static-swift-stdlib) + fi + "${BUILD_ARGS[@]}" + + SHOW_BIN_ARGS=(swift build -c release --product CodexBarCLI --show-bin-path) + if [[ -n "${{ matrix.build-arch }}" ]]; then + SHOW_BIN_ARGS+=(--arch "${{ matrix.build-arch }}") + fi + if [[ "${{ matrix.static-swift-stdlib }}" == "true" ]]; then + SHOW_BIN_ARGS+=(--static-swift-stdlib) + fi + + echo "bin_dir=$("${SHOW_BIN_ARGS[@]}")" >> "$GITHUB_OUTPUT" + + - name: Smoke test CodexBarCLI + timeout-minutes: 5 + shell: bash + run: | + set -euo pipefail + + BIN_DIR="${{ steps.build.outputs.bin_dir }}" + BIN="$BIN_DIR/CodexBarCLI" + run_with_timeout() { + local output="$1" + shift + "$@" > "$output" & + local pid=$! + local run_status= + for _ in {1..20}; do + if ! kill -0 "$pid" 2>/dev/null; then + set +e + wait "$pid" + run_status=$? + set -e + break + fi + sleep 1 + done + if [[ -z "$run_status" ]]; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + echo "$* timed out." >&2 + exit 124 + fi + if [[ "$run_status" -ne 0 ]]; then + cat "$output" >&2 || true + exit "$run_status" + fi + } + + echo "BIN=$BIN" + file "$BIN" + if [[ "${{ matrix.platform }}" == "macos" ]]; then + lipo -archs "$BIN" | tr ' ' '\n' | grep -Fx "${{ matrix.asset-arch }}" + run_with_timeout "$RUNNER_TEMP/codexbar-cli-smoke-${{ matrix.name }}.txt" "$BIN" config validate --format json + else + run_with_timeout "$RUNNER_TEMP/codexbar-cli-help-${{ matrix.name }}.txt" "$BIN" --help + file "$BIN" | grep -q "${{ matrix.asset-arch }}" + fi + printf '%s\n' "${RELEASE_TAG#v}" > "$BIN_DIR/VERSION" + VERSION_OUTPUT="$RUNNER_TEMP/codexbar-cli-version-${{ matrix.name }}.txt" + run_with_timeout "$VERSION_OUTPUT" "$BIN" --version + grep -Fx "CodexBar ${RELEASE_TAG#v}" "$VERSION_OUTPUT" + rm "$BIN_DIR/VERSION" - name: Package id: pkg @@ -71,47 +186,115 @@ jobs: run: | set -euo pipefail - TAG="${GITHUB_REF_NAME}" - if [[ -z "$TAG" ]]; then - echo "Missing tag (GITHUB_REF_NAME)." >&2 + REF_NAME="${RELEASE_TAG}" + if [[ -z "$REF_NAME" ]]; then + echo "Missing release tag." >&2 exit 1 fi + SAFE_REF_NAME="${REF_NAME//\//-}" - ARCH="$(uname -m)" - case "$ARCH" in - x86_64) ARCH="x86_64" ;; - aarch64|arm64) ARCH="aarch64" ;; - esac - - BIN_DIR="$(swift build -c release --product CodexBarCLI --static-swift-stdlib --show-bin-path)" + BIN_DIR="${{ steps.build.outputs.bin_dir }}" OUT_DIR="$(mktemp -d)" install -m 0755 "$BIN_DIR/CodexBarCLI" "$OUT_DIR/CodexBarCLI" ln -s "CodexBarCLI" "$OUT_DIR/codexbar" + printf '%s\n' "${SAFE_REF_NAME#v}" > "$OUT_DIR/VERSION" - ASSET="CodexBarCLI-${TAG}-linux-${ARCH}.tar.gz" - (cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar) - sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256" + ASSET="CodexBarCLI-${SAFE_REF_NAME}-${{ matrix.platform }}-${{ matrix.asset-arch }}.tar.gz" + (cd "$OUT_DIR" && tar czf "$ASSET" CodexBarCLI codexbar VERSION) + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256" + else + shasum -a 256 "$OUT_DIR/$ASSET" > "$OUT_DIR/$ASSET.sha256" + fi echo "out_dir=$OUT_DIR" >> "$GITHUB_OUTPUT" echo "asset=$ASSET" >> "$GITHUB_OUTPUT" - name: Upload release assets - if: github.event_name == 'release' + if: github.event_name == 'release' || inputs.tag != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF_NAME}" + TAG="${RELEASE_TAG}" OUT_DIR="${{ steps.pkg.outputs.out_dir }}" ASSET="${{ steps.pkg.outputs.asset }}" gh release upload "$TAG" "$OUT_DIR/$ASSET" "$OUT_DIR/$ASSET.sha256" --clobber - name: Upload workflow artifact (manual runs) - if: github.event_name != 'release' + if: github.event_name != 'release' && inputs.tag == '' uses: actions/upload-artifact@v6 with: - name: codexbar-linux-cli-${{ matrix.name }} + name: codexbar-cli-${{ matrix.name }} path: | ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }} ${{ steps.pkg.outputs.out_dir }}/${{ steps.pkg.outputs.asset }}.sha256 + + update-homebrew-tap: + runs-on: ubuntu-latest + needs: build-cli + if: github.event_name == 'release' || inputs.tag != '' + steps: + - name: Resolve release tag + id: release + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + if [[ -z "$tag" ]]; then + echo "Missing release 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 }} + shell: bash + run: | + set -euo pipefail + test -n "$GH_TOKEN" + for attempt in {1..6}; do + if gh workflow run update-formula.yml \ + --repo steipete/homebrew-tap \ + -f formula=codexbar \ + -f tag="${{ steps.release.outputs.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 request_id="${{ steps.release.outputs.request_id }}"; then + exit 0 + fi + if [[ "$attempt" -eq 6 ]]; then + echo "Failed to dispatch tap update after ${attempt} attempts." >&2 + exit 1 + fi + sleep $((attempt * 30)) + done + + - name: Wait for tap update + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + shell: bash + run: | + set -euo pipefail + for _ in {1..20}; do + run_id="$( + gh run list \ + --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 \ + | head -n1 || true + )" + if [[ -n "$run_id" ]]; then + gh run watch "$run_id" --repo steipete/homebrew-tap --exit-status + exit 0 + fi + cat /tmp/codexbar-tap-run-list.err >&2 || true + sleep 5 + done + echo "Timed out waiting for tap workflow to appear." >&2 + exit 1 diff --git a/.github/workflows/upstream-monitor.yml b/.github/workflows/upstream-monitor.yml index 04140e8d6..3deb7b70d 100644 --- a/.github/workflows/upstream-monitor.yml +++ b/.github/workflows/upstream-monitor.yml @@ -44,21 +44,49 @@ jobs: - name: Check for new commits id: check run: | + remote_default_branch() { + local remote="$1" + local branch="" + branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true) + if [ -z "$branch" ]; then + branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true) + fi + if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then + echo "$branch" + return 0 + fi + for candidate in main master; do + if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then + echo "$candidate" + return 0 + fi + done + echo "Could not resolve default branch for ${remote}" >&2 + exit 1 + } + + UPSTREAM_BRANCH=$(remote_default_branch upstream) + QUOTIO_BRANCH=$(remote_default_branch quotio) + UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" + QUOTIO_REF="quotio/${QUOTIO_BRANCH}" + echo "upstream_ref=$UPSTREAM_REF" >> $GITHUB_OUTPUT + echo "quotio_ref=$QUOTIO_REF" >> $GITHUB_OUTPUT + # Count new commits in upstream - UPSTREAM_NEW=$(git log --oneline main..upstream/main --no-merges 2>/dev/null | wc -l | tr -d ' ') + UPSTREAM_NEW=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') echo "upstream_commits=$UPSTREAM_NEW" >> $GITHUB_OUTPUT # Count new commits in quotio (last 7 days) - QUOTIO_NEW=$(git log --oneline --all --remotes=quotio/main --since="7 days ago" 2>/dev/null | wc -l | tr -d ' ') + QUOTIO_NEW=$(git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | wc -l | tr -d ' ') echo "quotio_commits=$QUOTIO_NEW" >> $GITHUB_OUTPUT # Get commit summaries echo "upstream_summary<> $GITHUB_OUTPUT - git log --oneline main..upstream/main --no-merges 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT + git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT echo "quotio_summary<> $GITHUB_OUTPUT - git log --oneline --remotes=quotio/main --since="7 days ago" 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT + git log --oneline "$QUOTIO_REF" --since="7 days ago" 2>/dev/null | head -10 >> $GITHUB_OUTPUT || echo "No commits" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create or update issue @@ -68,6 +96,10 @@ jobs: script: | const upstreamCommits = '${{ steps.check.outputs.upstream_commits }}'; const quotioCommits = '${{ steps.check.outputs.quotio_commits }}'; + const upstreamRef = '${{ steps.check.outputs.upstream_ref }}'; + const quotioRef = '${{ steps.check.outputs.quotio_ref }}'; + const upstreamBranch = upstreamRef.replace('upstream/', ''); + const quotioBranch = quotioRef.replace('quotio/', ''); const upstreamSummary = `${{ steps.check.outputs.upstream_summary }}`; const quotioSummary = `${{ steps.check.outputs.quotio_summary }}`; @@ -75,6 +107,7 @@ jobs: **steipete/CodexBar:** ${upstreamCommits} new commits **quotio:** ${quotioCommits} new commits (last 7 days) + **Source refs:** ${upstreamRef}, ${quotioRef} ### steipete/CodexBar Recent Commits \`\`\` @@ -100,13 +133,13 @@ jobs: **View detailed diffs:** \`\`\`bash - git diff main..upstream/main - git log -p quotio/main --since='7 days ago' + git diff main..${upstreamRef} + git log -p ${quotioRef} --since='7 days ago' \`\`\` ### 🔗 Links - - [steipete commits](https://github.com/steipete/CodexBar/compare/${context.sha}...steipete:CodexBar:main) - - [quotio commits](https://github.com/nguyenphutrong/quotio/commits/main) + - [steipete commits](https://github.com/steipete/CodexBar/compare/${context.sha}...steipete:CodexBar:${upstreamBranch}) + - [quotio commits](https://github.com/nguyenphutrong/quotio/commits/${quotioBranch}) --- *Auto-generated by upstream-monitor workflow* @@ -153,4 +186,3 @@ jobs: echo "✅ No new upstream changes detected" echo "steipete/CodexBar: up to date" echo "quotio: no commits in last 7 days" - diff --git a/.gitignore b/.gitignore index d44f986f6..8bb2ec1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ debug_*.swift .vscode/ .codex/environments/ .swiftpm-cache/ +.tmp-clang/ # Debug/analysis docs docs/*-analysis.md diff --git a/AGENTS.md b/AGENTS.md index a4c8e630a..ec66eaf03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,9 @@ ## Testing Guidelines - Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods). -- Always run `swift test` (or `./Scripts/compile_and_run.sh`) before handoff; add fixtures for new parsing/formatting scenarios. -- After any code change, run `pnpm check` and fix all reported format/lint issues before handoff. +- Always run `swift test` before handoff; add focused filters for parser/provider fixes when possible. +- After any code change, run `make check` and fix all reported format/lint issues before handoff. +- Prefer CLI/focused tests over app-bundle live tests when behavior can be verified without relaunching CodexBar. - macOS CI is brittle around headless AppKit status/menu tests. Prefer covering menu behavior through stable state/model seams (`MenuDescriptor`, `ProvidersPane`, `CodexAccountsSectionState`, etc.) instead of constructing live `NSStatusBar`/`NSMenu` flows unless the AppKit wiring itself is the thing under test. ## Commit & PR Guidelines @@ -28,13 +29,13 @@ ## Agent Notes - Use the provided scripts and package manager (SwiftPM); avoid adding dependencies or tooling without confirmation. -- Validate behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries. +- Validate UI/runtime behavior against the freshly built bundle; restart via the pkill+open command above to avoid running stale binaries. - To guarantee the right bundle is running after a rebuild, use: `pkill -x CodexBar || pkill -f CodexBar.app || true; cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app`. -- After any code change that affects the app, always rebuild with `Scripts/package_app.sh` and restart the app using the command above before validating behavior. -- If you edited code, run `scripts/compile_and_run.sh` before handoff; it kills old instances, builds, tests, packages, relaunches, and verifies the app stays running. -- Per user request: after every edit (code or docs), rebuild and restart using `./Scripts/compile_and_run.sh` so the running app reflects the latest changes. +- For CLI-testable provider/parser/settings behavior, use CLI/focused tests instead of `Scripts/package_app.sh` or `./Scripts/compile_and_run.sh`. +- Run `./Scripts/compile_and_run.sh` only when UI/runtime behavior needs bundle-level validation; it builds, tests, packages, relaunches, and verifies the app stays running. - Release script: keep it in the foreground; do not background it—wait until it finishes. - Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). +- Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). - Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.*** diff --git a/CHANGELOG.md b/CHANGELOG.md index ee814c1d2..81c1102c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,228 @@ # Changelog -## 0.24 — Unreleased +## 0.27.0 — 2026-05-17 + +### Added +- Deepgram: add API-key usage tracking with project discovery and speech/agent usage breakdowns (#1003, fixes #994). Thanks @czjzpz! +- Grok: add xAI Grok provider support with local identity detection and billing decoding for the Grok CLI integration (#965). Thanks @taibaran! +- ElevenLabs: add API-key usage tracking for subscription credits, reset time, and voice-slot limits. +- MiniMax: add web-session billing-history summaries with 30-day token charts and top model/method breakdowns (#1007). +- Usage charts: reuse the OpenAI API inline dashboard for local Codex/Claude/Vertex/Bedrock cost history, OpenRouter day/week/month spend, z.ai hourly tokens, and Mistral daily spend. +- Claude: add an Anthropic Admin API source and allow `sk-ant-admin...` keys in Claude token accounts for API spend/token tracking (#966). +- OpenCode Go: show the optional Zen pay-as-you-go balance from the workspace dashboard alongside subscription windows (#1006). +- Kiro: add overage-credit and overage-cost menu bar display modes for exhausted plans (#972). Thanks @raflyazf! +- Quota warnings: include the triggering account in notification copy when personal info is visible (#973). Thanks @raflyazf! +- CLI: add `codexbar config set-api-key` for safely storing provider API keys from stdin. +- CLI: add `codexbar config providers`, `enable`, and `disable` for scripting the same provider toggles used by Settings. +- Website: replace provider-letter tiles with brand logos, add light/dark landing-page themes, and collapse OpenCode/OpenCode Go into one company entry (#989). Thanks @pasangimhana! +- Providers: route app-owned provider HTTP calls through a shared transport seam for cleaner proxy and test support (#892). Thanks @serezha93! + +### Fixed +- Codex: improve multi-account switching with quota-aware ordering, workspace grouping, persisted per-account snapshots, health labels, and auth fingerprint matching. +- Overview: expose provider chart and storage detail submenus from overview rows instead of requiring a provider-tab switch first. +- Claude: reset stuck CLI sessions after usage probe timeouts, give slow probes longer to render, and keep stale data visible across transient timeouts. +- Claude: keep the last successful usage card visible across transient probe timeouts while still clearing stale data after Claude auth changes. +- Grok: retry transient web billing timeouts once and allow slower billing RPCs to finish before showing an error. +- Claude: de-duplicate copied fork/resume transcript history by provider response identity so local cost estimates do not overcount repeated rows (#1002). Thanks @Neverdie-2! +- Claude: keep Team and Personal Max plan-utilization history separate when the same email appears on multiple Claude accounts (#213). +- OpenAI: shorten the provider label to "OpenAI" so the menu tab no longer clips. +- OpenAI: accept numeric-string Admin API cost amounts so usage does not fail when `/v1/organization/costs` returns `"amount": { "value": "12.50" }` (#999, #1000). Thanks @SergeyLavrentev! +- Menu: keep provider switcher buttons centered by moving quota indicators out of the button layout. +- Menu: keep the persistent Refresh row at a fixed height while highlighted or pressed so nearby items no longer jump (#1001). +- Menu bar: avoid re-reading provider credentials, Codex account state, Claude terminal probe text, and storage footprints on hot menu paths, reducing idle CPU while providers are still loading. +- Menu bar: skip unchanged split-provider icon redraws and avoid an extra animation-state scan during blink ticks. +- Codex: prefer per-event token usage over divergent total counters when scanning local cost history, preventing large false cost spikes (#968). Thanks @Ifan24! +- Codex: improve managed account login recovery guidance when macOS blocks or moves a stale `codex` CLI to Trash (#977). +- Claude: label Extra usage denominators as the monthly cap so recharge balances are not confused with the maximum spend limit (#975). +- Claude: wait for the CLI usage panel to finish rendering after the Current session label so slow Claude Code builds do not produce false "Missing Current session" errors (#959). +- Claude: label five-hour session pace as "Projected empty" so it is not confused with the reset countdown (#960). +- Claude: show Enterprise spend-limit usage in automatic menu bar metrics and expose the Extra usage metric picker when spend data is available (#964). +- Grok: fall back to grok.com's billing endpoint when `grok agent stdio` omits the xAI billing method (#984). Thanks @bcharleson! +- MiniMax: show Coding Plan model-remains quotas as used/limit cards and include weekly text-generation quota windows (#970). Thanks @Yuxin-Qiao! +- Ollama: let automatic session import fall back from Chrome to Safari, Comet, and the rest of the browser import order when Chrome has no Ollama session (#962). +- Kimi K2: label the legacy provider as unofficial and remove links that presented the legacy endpoint as an official Kimi account surface (#967, fixes #473). Thanks @mturac! +- Menu bar: recover visible status items after the display hosting the menu bar item is unplugged (#998, fixes #997). Thanks @Llldmiao! +- Menu bar: recreate status items on startup when macOS reports them visible but never attaches a menu bar button/window (#988). +- CLI: use explicit provider HTTP timeouts so blocked network connections fail instead of leaving usage commands stuck for days (#1005, fixes #1004). Thanks @msmolkin! +- CLI: reject non-loopback `Host` headers in `codexbar serve` before serving local usage and cost metadata (#995). Thanks @rohitjavvadi! +- Packaging: skip slow widget App Intents metadata during dev restarts and preserve the previous app bundle if required metadata generation times out. +- Localization: fall back to English when a bundled localized string is blank instead of rendering empty menu/settings text (#952). Thanks @xiaoqianWX! +- Settings: localize the provider storage usage toggle in the Advanced pane (#985, fixes #971). Thanks @tanish19078! + +## 0.26.1 — 2026-05-15 + +### Added +- OpenAI API: show Admin API usage inline with Today/7d/30d summaries, a 30-day spend graph, and an interactive detail chart for daily spend, tokens, and requests. +- CLI: add `codexbar serve` for localhost JSON access to usage and cost endpoints (#957). Thanks @ThiagoCAltoe! + +### Fixed +- OpenCode Go: block cross-host redirects when fetching usage so imported cookies cannot follow external redirect targets (#969). Thanks @pavbar! +- Codex: keep background `/status` probes out of Codex Desktop history by using isolated non-persistent CLI storage (#953). +- Menu: stabilize the Cost submenu by using a native menu item and deferring open-menu rebuilds while tracking (#954). Thanks @getogrand! +- Localization: add Brazilian Portuguese quota-warning settings strings (#958). Thanks @ThiagoCAltoe! + +## 0.26.0 — 2026-05-15 + +### Added +- Codex: add tiered long-context and Fast/Priority pricing to local cost history using local app-server priority traces (#917). Thanks @iam-brain! +- Kiro: show account/auth details, plan labels, credit and bonus-credit balances, overage state, and Kiro-specific menu bar display options (#933, fixes #934). Thanks @solnikhil! +- Antigravity: add Google OAuth token-account switching with selected-account refresh persistence (#937, fixes #936). Thanks @hhh2210! +- OpenRouter: show daily and weekly API key spend from `/api/v1/key` in the menu (#685). Thanks @ThiagoCAltoe! +- Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! +- Menu: add left/right arrow keyboard navigation for the merged provider switcher (#266). +- Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! +- AWS Bedrock: add Cost Explorer usage and monthly budget tracking (#897). Thanks @afalk42! +- Kilo: add organization selection, scoped organization fetches, and stacked Kilo usage cards (#920). Thanks @NoeFabris! +- Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! +- z.ai: add an hourly per-model token usage chart in the menu (#913). Thanks @n1majne3! +- Localization: add Brazilian Portuguese translations (#902). Thanks @ThiagoCAltoe! +- Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! + +### Fixed +- Codex: show authenticated plan/account rows as "Limits not available" instead of a red no-rate-limit error when Codex reports profile data but no rate-limit windows yet. +- Overview: hide provider rows that only contain an error, and avoid showing a one-item Codex System Account submenu. +- Menu: disable implicit provider-switcher layer animations and reuse the deferred rebuild path so open menus stay stable under pointer movement (#950). +- Menu: defer account-switcher menu rebuilds so switching Codex or token accounts does not send the open menu into a flicker loop (#946, fixes #944). Thanks @kubahasek! +- Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! +- Codex: scope local cost history to the selected managed account's `CODEX_HOME` and label cost cards as local-log estimates (#910). +- Cost history: label local log totals as API-rate estimates in menu cards, charts, and CLI output (#926). Thanks @yashiels! +- Cursor: open Add Account in the user's browser and import the resulting browser session instead of trapping login in an embedded web view (#922). +- Claude: handle Enterprise and organization spend-limit usage across OAuth/web accounts, including null session quota windows, inline spend-limit usage, `extra_usage`-only responses, and token-account Org ID support (#925, #941, fixes #940). Thanks @clintandrewhall! +- OpenCode Go: let automatic cookie import scan all supported browser sources instead of Chrome only (#665). +- Copilot: preserve over-quota usage so paid overage can show above 100% instead of clamping to exhausted (#818). +- Codex: pause background CLI launches after macOS blocks or quarantines `codex`, avoiding repeated "Malware Blocked" prompts (#942). +- Claude: clarify that local cost/token estimates include cache read/write tokens and may differ from Claude Code `/status` (#781, #787). +- Updates: make the restart/apply-update menu action use Sparkle's prepared install callback on the first click (#947). Thanks @velvet-shark! +- Multi-account menus: keep stacked token-account cards capped to current accounts and ignore stale snapshots from removed accounts (#949). +- Droid: accept pasted Factory `Authorization: Bearer` headers and bearer tokens for manual sessions when cookies alone are insufficient (#914). +- Menu bar: detect when macOS Tahoe hides CodexBar behind the new Allow in Menu Bar setting and show recovery guidance (#945, fixes #890). Thanks @pdurlej! +- CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). +- Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! +- Gemini: refresh OAuth credentials when the CLI has a refresh token but no cached access token instead of reporting "not logged in" after authentication (#915). +- Gemini: label OAuth-backed API fetches as `oauth-api` instead of plain `api` (#930). Thanks @ThiagoCAltoe! +- Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! +- Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991! +- Menu: middle-truncate long account emails in Codex account controls and keep the Codex account switcher visible during merged-menu refreshes with transient account snapshots. +- Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). +- Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. +- ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! +- z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe! +- Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! +- Release: include macOS platform and architecture in notarized app and dSYM asset names (#164). +- Upstream tooling: resolve remote default branches and tolerate missing upstream remotes in review scripts (#906). + +## 0.25.1 — 2026-05-11 + +### Fixed +- Settings: avoid packaged-app crashes from SwiftPM localization bundle lookup when opening Settings or About (#896, fixes #891). Thanks @lederniermagicien! +- CLI: include a VERSION file in standalone release archives so `--version` reports the release tag outside the app bundle (#898). Thanks @ThiagoCAltoe! +- Pi: rebuild stale session cost caches after cache-version migrations so refreshed cost history reflects current scanner data. +- Keychain cache: reduce repeated development prompt churn by trusting the bundled helper when writing CodexBar-owned cache items (#888). + +## 0.25 — 2026-05-10 + +### Highlights +- Localization: add Simplified Chinese app strings and an in-app language selector (#819). Thanks @markhome1! +- New providers: Manus, MiMo, Qwen, Doubao, Command Code, StepFun, Crof, Venice, and OpenAI API balance support. +- MiniMax: add multi-service quota cards for text, speech, image, video, and music coding-plan usage (#605). Thanks @XWind18! +- Notifications: add opt-in quota warning notifications, warning markers, and provider-level thresholds for session and weekly quota windows (#852). Thanks @Alekstodo! +- Codex: add stacked multi-account switchers and show official Pro 5x/Pro 20x plan labels (#869, #882). Thanks @ajmccall and @xiaoqianWX! +- Cost history: use live models.dev pricing metadata, preserve tiered pricing boundaries, and keep large Codex/Claude log scans incremental (#863, #884, #886). Thanks @iam-brain! +- Menu bar: fix hidden/stale status items, keep manual refreshes open, and improve balance-style menu bar text for providers without useful quota percentages (#845, #853, #861). Thanks @OlimjonovOtabek and @willytop8! +- Accessibility: add VoiceOver labels for status icons, menu rows, provider switcher buttons, and usage charts (#860, fixes #859). Thanks @WadydX! + +### Providers & Usage +- Manus: add browser-cookie provider support for credit balance, monthly credits, and daily refresh tracking (#700). Thanks @hhh2210! +- MiMo: add browser-cookie provider support for Xiaomi token-plan usage, plan labels, balance fallback, CLI, widget, and docs (#651). Thanks @debpramanik! +- Qwen and Doubao: add API-key provider support for Alibaba Qwen and Volcengine Ark request-limit tracking (#498). Thanks @LeoLin990405! +- MiniMax: add multi-service quota cards for text, speech, image, video, and music coding-plan usage (#605). Thanks @XWind18! +- Antigravity: add OAuth-backed remote usage fetching so quotas can refresh even when the IDE is closed (#635). Thanks @abnormal749! +- Venice: add API-key balance provider support with DIEM/USD balance display and token-account CLI wiring (#865). Thanks @clawSean! +- Crof: add API-key provider support with request quota and credit balance tracking (#872). Thanks @baanish! +- OpenAI API: add optional platform credit-balance tracking from the billing credit-grants endpoint (#877). +- Command Code: add browser-cookie provider support for monthly USD billing credits (#857). Thanks @sixhobbits! +- StepFun: add username/password or Oasis-Token provider support for Step Plan rate-limit tracking (#815). Thanks @tevenfeng! +- Factory/Droid: add token-rate-limit billing windows, Core fallback buckets, and extra usage balance display (#878). Thanks @dantemoon1! +- OpenRouter, Mistral, and Kimi K2: show balance/spend metrics in menu bar text when quota percentage is not useful (#853). Thanks @willytop8! +- Usage pace: show session-level pace indicators for Codex and Claude 5-hour windows, and compute pace for any explicit reset window instead of a provider allowlist (#355, #875). Thanks @johnlarkin1 and @ViperThanks! +- Cost history: add a models.dev pricing metadata parser/cache pipeline and prefer cached models.dev pricing for Codex and Claude before bundled fallback tables (#863, #884). Thanks @iam-brain! +- Browser cookies: bump SweetCookieKit to 0.4.1 for Comet and Yandex browser discovery, Safari profile cookie stores, and per-browser Chromium Safe Storage keys. + +### Menu & Settings +- Codex: add a stacked multi-account menu layout for account switchers (#869). Thanks @ajmccall! +- Notifications: add opt-in quota warning notifications, warning markers, and provider-level thresholds for session and weekly quota windows (#852). Thanks @Alekstodo! +- Accessibility: add VoiceOver labels for status icons, menu rows, provider switcher buttons, and usage charts (#860, fixes #859). Thanks @WadydX! +- Menu bar: keep status items visible on launch by avoiding macOS autosaved hidden menu-extra state from v0.24 (#861). +- Menu bar: remove stale split provider status items instead of hiding them, avoiding leftover second-icon slots on macOS 26.4. +- Menu: keep the status menu open when manually refreshing usage from the menu (#845). Thanks @OlimjonovOtabek! +- Menu: route provider switcher tab clicks through the parent view's mouse tracking so a sub-provider tab still responds after switching back from the Overview tab (#867). Thanks @Karl-Dai! +- Menu: keep long Codex account labels from widening the status menu when switching to the Codex tab. +- Menu: keep Cost and Subscription Utilization submenus stable by deferring parent card rebuilds while hosted submenus are open (#862). +- Settings: avoid a crash when opening the display overview provider picker. + +### Fixes +- Startup: avoid blocking menu-bar creation on synchronous defaults migration/default seeding when macOS preferences services stall. +- Codex: honor the legacy `openAIWebAccess` defaults key when importing OpenAI web extras preferences, so existing terminal workarounds no longer get ignored on launch (#794). +- Codex: restrict OAuth auto fallback to missing/invalid auth so transient API/decode errors do not spawn `codex app-server` and burn tokens (#876, fixes #874). Thanks @ViperThanks! +- Codex: show official Pro 5x/Pro 20x plan labels instead of Pro Lite/Pro in menu and CLI output (#882). Thanks @xiaoqianWX! +- Cost history: keep manual refreshes on the incremental scanner cache and drain per-line JSON parse allocations so large Codex/Claude histories do not trigger full local log rescans and CPU/memory spikes. +- Cost history: preserve cached models.dev pricing when an upstream catalog only changes a pinned snapshot suffix for the same model family (#883). Thanks @iam-brain! +- Cost history: preserve per-request tiered pricing boundaries when aggregating Claude/Pi daily reports (#886). Thanks @iam-brain! +- Keychain cache: trust the bundled CodexBarCLI helper when writing CodexBar-owned cache items, reducing repeated "CodexBar Cache" prompts from CLI usage (#679). Thanks @QuarkAssistant! +- Locale: keep relative timestamps in hardcoded-English UI labels consistently English on non-English macOS systems (#868, fixes #866). Thanks @Karl-Dai! +- Droid: send the bearer JWT subject as the usage `userId` when Factory omits `userProfile.id`, avoiding false login failures (#626). Thanks @CrystalChen1017! +- Droid: fall back to token/allowance math when the Factory API reports a zero ratio despite non-zero usage (#864). Thanks @proxynico! +- Alibaba: point the International Coding Plan dashboard link at the current `coding_plan` route and clarify unsupported API-key quota errors (#612). +- Claude: allow web/sessionKey token accounts to specify `organizationId` so linked Anthropic emails can target the intended org (#848). +- DeepSeek: show a positive CNY balance when the API also returns an empty USD balance (#873). +- Vertex AI: detect service-account ADC files from `GOOGLE_APPLICATION_CREDENTIALS` and use `gcloud` to fetch access tokens (#871). +- Gemini: retry direct API requests with curl when URLSession times out on hosts where curl succeeds (#826). +- Gemini: locate Homebrew-installed CLI bundles and parse bundled OAuth client constants so token refresh works with newer `gemini-cli` installs (#695). +- OpenRouter: keep the menu bar rendering the usage meter instead of falling back to the provider logo when no key limit is configured (#854). Thanks @willytop8! +- DeepSeek: show balance as plain text instead of a misleading quota-style progress bar (#856). Thanks @jb381! +- Augment: report the real 1-minute keepalive check/min-refresh intervals in startup logs and docs (#434). Thanks @guglielmofonda! +- Website: refresh codex.bar with the current canonical domain, structured background, and updated social preview. + +## 0.24 — 2026-05-06 + +### Providers & Usage +- Windsurf: add provider support with web-session usage fetching and local SQLite-cache fallback (#583). Thanks @Coooolfan! +- Codebuff: add provider support with credit balance tracking, weekly rate-limit usage, API-token settings, and `codebuff login` credential import (#837). Thanks @anandghegde! +- Copilot: add multi-account support with GitHub OAuth sign-in, account switching, and per-account usage cards (#637). Thanks @ajmccall! +- DeepSeek: add provider support with token-account balance tracking, paid vs. granted credit breakdown, and CLI support (#811). Thanks @willytop8! +- Storage: add an opt-in menu view for local provider storage usage with background scans and copyable path breakdowns (#829). Thanks @fatiheminoge! +- OpenRouter and DeepSeek: show remaining account balances in the menu bar, while preserving OpenRouter's API-key limit metric when explicitly selected (#832). Thanks @giuseppebisemi! +- Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! +- Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! +- Copilot: support GitHub Enterprise hosts for the device-flow login and usage API paths (#827). Thanks @ramzesenok! +- Alibaba: clarify China-region API-key failures when the console endpoint requires a browser session (#628). Thanks @XWind18! + +### Fixes +- Codex: time out hung `codex app-server` RPC reads and cap loading animation runtime so stalled refreshes no longer keep the menu bar redrawing indefinitely (#842, #844). Thanks @hyspacex! +- Codex: make OpenAI dashboard refreshes handle non-English pages, lazy-loaded credits history, timeout retries, and unrelated Skillusage rows (#825). Thanks @xiaoqianWX! +- Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00! +- Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! +- Claude: enable Claude and switch to OAuth after a successful login, clear stale selected-provider state when Claude is disabled, and tolerate OAuth payloads that omit the five-hour window (#816, #726). Thanks @pdurlej and @Brandawg93! +- Claude: recognize OAuth `subscriptionType` before `rateLimitTier` so Pro accounts with generic Claude Code tiers + open the subscription usage dashboard correctly (#836, fixes #824). Thanks @shixy96! +- Usage: preserve known reset countdowns when a refresh returns current usage without reset metadata (#427). Thanks @Whoaa512! +- Menu: refresh open usage cards after live data changes so the “Updated” timestamp advances after manual or cadence refreshes (#715). Thanks @cooper-matt! +- Menu: make the global open-menu shortcut behave as a true toggle when the menu is already open, avoiding queued reopens after repeated key presses (#218). +- Menu bar: preserve existing status items and assign stable autosave names so provider icon positions survive provider toggles (#538). Thanks @hxy91819! +- Settings: make the Preferences window 10% wider and taller so dense provider/settings panes have more breathing room. +- CLI releases: publish macOS arm64 and x86_64 CLI tarballs alongside Linux artifacts, with release-workflow smoke tests and docs (#457, #839). Thanks @androidshu and @mondary! +- CLI: query only enabled providers by default when three or more providers are enabled instead of expanding to every registered provider (#830). Thanks @lhoBas! +- CLI: read MiniMax coding-plan tokens from `MINIMAX_CODING_API_KEY`, accept Alibaba Qwen/DashScope API-key aliases, and avoid duplicate generic JSON error rows after provider failures. +- CLI discovery: prefer known install paths before interactive shell probing so common Claude installs no longer run shell init hooks during binary detection (#775). +- CLI lookup: drain login-shell probe output and terminate spawned process groups so interactive shell helpers cannot leak after path detection (#822, fixes #821). Thanks @LPFchan! +- OpenCode Go: open the workspace-specific usage dashboard when a workspace ID is configured (#667). Thanks @RizaSatya! +- Augment: use the API-provided credits limit when available instead of reconstructing the limit from consumed plus remaining credits (#338). Thanks @bcharleson! +- MiniMax: ignore login strings embedded in scripts when checking web-session pages for signed-out state (#508). Thanks @qipihen! +- Accounts: refresh the selected provider data and open menu after switching token accounts, even while a menu-open refresh is running (#799, fixes #798). Thanks @Zeko369! +- Codex: prefer session turn-context model metadata when calculating local cost history so GPT-5.4 sessions are not bucketed as GPT-5 (#620). Thanks @betive37! +- Codex: stop falling back from app-server RPC to bare CLI TUI during automatic usage refreshes, preventing unexpected OpenAI auth browser tabs. +- Menu/keychain: block delayed test-time menu mutations after teardown and enforce no-UI keychain reads more reliably (#381). Thanks @artuskg! +- Menu bar: fix invisible status item icon on macOS 26.4 by removing remaining RenderBox-triggering SwiftUI compositing modifiers from `UsageProgressBar` (rewritten as a single Canvas) and eliminating ~28 redundant Keychain reads on every launch after the first-run migration (#805). Thanks @willytop8! ## 0.23 — 2026-04-26 @@ -85,6 +307,7 @@ - Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled. ### Development & Tooling +- CLI / Debug: add user-facing browser-cookie cache clearing, including provider-scoped CLI clearing that removes managed Codex account cookie caches (#592, fixes #591). Thanks @coygeek! - Diagnostics: add lightweight battery instrumentation for menu updates and refresh work (#708). - Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with `--clear-adhoc-keychain`, and extend the explicit reset path to clear both `com.steipete.CodexBar` and `com.steipete.codexbar.cache`. Thanks @magnaprog! diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..da971da46 --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +SHELL := /bin/bash + +.PHONY: build check docs-list format lint release restart start start-debug start-release stop test test-live test-tty + +start: + ./Scripts/compile_and_run.sh + +start-debug: + ./Scripts/compile_and_run.sh + +start-release: + ./Scripts/package_app.sh release + pkill -x CodexBar || pkill -f CodexBar.app || true + cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app + +restart: start + +stop: + pkill -x CodexBar || pkill -f CodexBar.app || true + +check lint: + ./Scripts/lint.sh lint + +format: + ./Scripts/lint.sh format + +docs-list: + node Scripts/docs-list.mjs + +build: + swift build + +test: + swift test + +test-tty: + swift test --filter TTYIntegrationTests + +test-live: + LIVE_TEST=1 swift test --filter LiveAccountTests + +release: + ./Scripts/package_app.sh release diff --git a/Package.resolved b/Package.resolved index 2dd5f9581..345845bc6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "6e0bbde3ad4d9af0981adadaf1f109eb154e54018d06dbe966616f09c3898482", + "originHash" : "9daf4612f2543e308a07be34ab3ccf2f4650427ce8086e5943eaed1fa79b64f4", "pins" : [ { "identity" : "commander", "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Commander", "state" : { - "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", - "version" : "0.2.1" + "revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b", + "version" : "0.2.2" } }, { @@ -33,8 +33,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/SweetCookieKit", "state" : { - "revision" : "4d5b71ffbb296937dc5ee8472f64721bca771cf0", - "version" : "0.4.0" + "revision" : "21bedea672a3e63ccad24d744051e76cdf0462dd", + "version" : "0.4.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" } }, { diff --git a/Package.swift b/Package.swift index e8e769f33..3dec48109 100644 --- a/Package.swift +++ b/Package.swift @@ -9,16 +9,18 @@ let useLocalSweetCookieKit = let sweetCookieKitDependency: Package.Dependency = useLocalSweetCookieKit && FileManager.default.fileExists(atPath: sweetCookieKitPath) ? .package(path: sweetCookieKitPath) - : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.0") + : .package(url: "https://github.com/steipete/SweetCookieKit", from: "0.4.1") let package = Package( name: "CodexBar", + defaultLocalization: "en", platforms: [ .macOS(.v14), ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/apple/swift-log", from: "1.12.0"), .package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"), .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"), @@ -31,6 +33,7 @@ let package = Package( name: "CodexBarCore", dependencies: [ "CodexBarMacroSupport", + .product(name: "Crypto", package: "swift-crypto"), .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], diff --git a/README.md b/README.md index d0e0474de..a43bf21da 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,25 @@ -# CodexBar 🎚️ - May your tokens never run out. +# CodexBar 🎚️ — May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, Perplexity, and Abacus AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +> Every AI coding limit, in your menu bar. -CodexBar menu screenshot +[![Latest release](https://img.shields.io/github/v/release/steipete/CodexBar?style=flat-square&color=0a0a0c)](https://github.com/steipete/CodexBar/releases/latest) +[![macOS 14+](https://img.shields.io/badge/macOS-14%2B-0a0a0c?style=flat-square)](https://github.com/steipete/CodexBar/releases/latest) +[![Homebrew](https://img.shields.io/badge/brew-steipete%2Ftap%2Fcodexbar-orange?style=flat-square)](https://github.com/steipete/homebrew-tap) +[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) +[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) + +CodexBar — every AI coding limit in your menu bar. 40+ providers. + +Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Vertex AI, Augment, OpenRouter, Codebuff, Command Code, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. + +CodexBar menu popover with provider tiles, usage bars, and reset countdowns + +## Why + +- **Plan around resets.** Per-provider session, weekly, and monthly windows with countdowns to the next reset — stop guessing whether to start that long task. +- **Credits, spend, and cost scans.** Credit balances, Admin API spend dashboards, provider billing summaries, and local cost scans where the source exposes enough detail. +- **Live status.** Provider status polling surfaces incident badges in the menu and an indicator overlay on the bar icon. +- **Privacy-first.** Reuses existing provider sessions — OAuth, device flow, API keys, browser cookies, local files — so no passwords are stored. ## Install @@ -17,78 +34,124 @@ Download: brew install --cask steipete/tap/codexbar ``` -### Linux (CLI only) +### CLI Tarballs (macOS/Linux) +Homebrew formula (Linux today): ```bash brew install steipete/tap/codexbar ``` -Or download `CodexBarCLI-v-linux-.tar.gz` from GitHub Releases. -Linux support via Omarchy: community Waybar module and TUI, driven by the `codexbar` executable. +Or download release tarballs from GitHub Releases: +- macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` +- Linux: `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` ### First run - Open Settings → Providers and enable what you use. -- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running). +- Install/sign in to the provider sources you rely on: CLIs, browser sessions, OAuth/device flow, API keys, local app files, or provider apps depending on the provider. - Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras. +### Set API keys from the CLI +Provider toggles and API keys live in `~/.codexbar/config.json`. You can script the same provider list that Settings → Providers uses: + +```bash +codexbar config providers +codexbar config enable --provider grok +codexbar config disable --provider cursor +``` + +For API-key providers, store a key without opening Settings: + +```bash +printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin +``` + +`set-api-key` trims the piped value, stores it with restrictive config-file permissions, and enables the provider by default. Use `--no-enable` to only save the key, or `--api-key ` for one-off local scripts where shell history is not a concern. +See [CLI configuration](docs/cli-configuration.md) for the full flow. + ## Providers -- [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras. -- [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage. +- [Codex](docs/codex.md) — OAuth API or local Codex CLI, plus optional OpenAI web dashboard extras. +- [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. - [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. - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. -- [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. +- [z.ai](docs/zai.md) — API token for quota + MCP windows. +- [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking. +- [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. -- [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals. -- [Kiro](docs/kiro.md) — CLI-based usage via `kiro-cli /usage` command; monthly credits + bonus credits. +- [Kimi K2 (unofficial)](docs/kimi-k2.md) — Legacy API key flow for credit-based usage totals. +- [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage. +- [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits. - [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs. -- [Augment](docs/augment.md) — Browser cookie-based authentication with automatic session keepalive; credits tracking and usage monitoring. +- [Augment](docs/augment.md) — Augment CLI or browser cookies for credits tracking and usage monitoring. - [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking. +- [Ollama](docs/ollama.md) — Browser cookies for Ollama Cloud usage windows. - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. +- [Warp](docs/warp.md) — API token for GraphQL request limits and monthly credits. +- [ElevenLabs](docs/elevenlabs.md) — API key for character credits and voice slot usage. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. +- [Windsurf](docs/windsurf.md) — Browser localStorage session import or local SQLite cache for plan usage. +- Perplexity — Account usage credits from Perplexity usage data. +- [Xiaomi MiMo](docs/mimo.md) — Browser cookies for balance and token-plan usage. +- [Doubao](docs/doubao.md) — API key for Volcengine Ark request-limit probes. - [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. +- Mistral — Browser cookies for monthly spend tracking. +- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown). +- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking. +- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking. +- [Codebuff](docs/codebuff.md) — API token (or `~/.config/manicode/credentials.json`) for credit balance + weekly rate limit. +- [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. +- [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback. +- [Deepgram](docs/deepgram.md) — API key usage summaries across speech, agent, token, and TTS metrics. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot -The menu bar icon is a tiny two-bar meter: -- Top bar: 5‑hour/session window. If weekly is missing/exhausted and credits are available, it becomes a thicker credits bar. -- Bottom bar: weekly window (hairline). -- Errors/stale data dim the icon; status overlays indicate incidents. +The menu bar icon is a tiny usage meter. Bar meaning is provider-specific, and errors/stale data can dim the icon or +show an incident indicator. ## Features - Multi-provider menu bar with per-provider toggles (Settings → Providers). -- Session + weekly meters with reset countdowns. +- Provider-specific usage meters with reset countdowns. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). -- Local cost-usage scan for Codex + Claude (last 30 days). +- Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, z.ai, MiniMax, Mistral, and AWS Bedrock. +- Local cost-usage scan for Codex + Claude (last 30 days), plus reused chart UI for supported provider histories. - Provider status polling with incident badges in the menu and icon overlay. -- Merge Icons mode to combine providers into one status item + switcher, with an optional Overview tab for up to three providers. +- Merge Icons mode to combine providers into one status item + switcher. +- Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection. - Refresh cadence presets (manual, 1m, 2m, 5m, 15m). -- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available. -- WidgetKit widget mirrors the menu card snapshot. +- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex`, `claude`, or `both` for local cost usage); macOS and Linux CLI builds available. +- WidgetKit widgets for supported providers. +- Optional session quota notifications and weekly-reset confetti. - Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored). ## Privacy note -Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, local JSONL logs) when the related features are enabled. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12). +Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, provider config files, local JSONL logs) when the related features are enabled. Provider tokens and token-account settings live in `~/.codexbar/config.json` with restrictive file permissions. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12). ## macOS permissions (why they’re needed) -- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead. +- **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers. If you don’t grant it, use another supported browser, manual cookies/API keys, OAuth, or CLI/local sources where that provider supports them. - **Keychain access (prompted by macOS)**: - - Chrome cookie import needs the “Chrome Safe Storage” key to decrypt cookies. - - Claude OAuth credentials (written by the Claude CLI) are read from Keychain when present. - - z.ai API token is stored in Keychain from Preferences → Providers; Copilot stores its API token in Keychain during device flow. + - Chromium cookie import needs the browser “Safe Storage” key to decrypt cookies. + - Claude OAuth bootstrap may read the Claude CLI Keychain item when CodexBar has no usable cached credentials. + - CodexBar may use Keychain for browser cookie decryption, cached cookie headers, and OAuth/device-flow credentials where those sources require it. - **How do I prevent those keychain alerts?** - - Open **Keychain Access.app** → login keychain → search the item (e.g., “Claude Code-credentials”). + - Open **Keychain Access.app** → login keychain → search the prompted item (for Claude OAuth, usually “Claude Code-credentials”). - Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”. - Prefer adding just CodexBar (avoid “Allow all applications” unless you want it wide open). - Relaunch CodexBar after saving. - Reference screenshot: ![Keychain access control](docs/keychain-allow.png) - **How to do the same for the browser?** - - Find the browser’s “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Firefox”, “Microsoft Edge Safe Storage”). + - Find the browser’s “Safe Storage” key (e.g., “Chrome Safe Storage”, “Brave Safe Storage”, “Microsoft Edge Safe Storage”). - Open the item → **Access Control** → add `CodexBar.app` under “Always allow access by these applications”. - This removes the prompt when CodexBar decrypts cookies for that browser. -- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs (codex/claude/gemini/antigravity). If those CLIs read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the CLI’s working directory, not background disk scanning. -- **What we do not request**: no Screen Recording, Accessibility, or Automation permissions; no passwords are stored (browser cookies are reused when you opt in). +- **Files & Folders prompts (folder/volume access)**: CodexBar launches provider CLIs and local probes for some providers. If those helpers read a project directory or external drive, macOS may ask CodexBar for that folder/volume (e.g., Desktop or an external volume). This is driven by the helper’s working directory, not background disk scanning. +- **What we do not request in the background**: no Screen Recording or Accessibility permissions; user-triggered helper actions may ask macOS for Automation permission to open Terminal. No passwords are stored (browser cookies are reused when you opt in). ## Docs - Providers overview: [docs/providers.md](docs/providers.md) @@ -96,21 +159,28 @@ Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it re - Issue labeling guide: [docs/ISSUE_LABELING.md](docs/ISSUE_LABELING.md) - UI & icon notes: [docs/ui.md](docs/ui.md) - CLI reference: [docs/cli.md](docs/cli.md) +- Configuration: [docs/configuration.md](docs/configuration.md) +- CLI configuration: [docs/cli-configuration.md](docs/cli-configuration.md) +- Widgets: [docs/widgets.md](docs/widgets.md) - Architecture: [docs/architecture.md](docs/architecture.md) - Refresh loop: [docs/refresh-loop.md](docs/refresh-loop.md) - Status polling: [docs/status.md](docs/status.md) - Sparkle updates: [docs/sparkle.md](docs/sparkle.md) +- Packaging: [docs/packaging.md](docs/packaging.md) +- Development: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) - Release checklist: [docs/RELEASING.md](docs/RELEASING.md) +- Changelog: [CHANGELOG.md](CHANGELOG.md) ## Getting started (dev) - Clone the repo and open it in Xcode or run the scripts directly. - Launch once, then toggle providers in Settings → Providers. -- Install/sign in to provider sources you rely on (CLIs, browser cookies, or OAuth). +- Install/sign in to provider sources you rely on (CLIs, browser cookies, OAuth/device flow, API keys, or local app/config files). - Optional: set OpenAI cookies (Automatic or Manual) for Codex dashboard extras. ## Build from source +Requires macOS 14+ and Swift 6.2+. + ```bash -swift build -c release # or debug for development ./Scripts/package_app.sh # builds CodexBar.app in-place CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh # ad-hoc signing (no Apple Developer account) open CodexBar.app @@ -119,6 +189,15 @@ open CodexBar.app Dev loop: ```bash ./Scripts/compile_and_run.sh +./Scripts/compile_and_run.sh --test # also run swift test before packaging/relaunching +make check # SwiftFormat + SwiftLint +make docs-list # list docs with frontmatter summaries +``` + +CLI install: +```bash +# after installing CodexBar.app in /Applications +./bin/install-codexbar-cli.sh ``` ## Related @@ -129,6 +208,9 @@ Dev loop: ## Looking for a Windows version? - [Win-CodexBar](https://github.com/Finesssee/Win-CodexBar) +## 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. + ## Credits Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking. diff --git a/Scripts/analyze_quotio.sh b/Scripts/analyze_quotio.sh index e3c6e76a1..2a4186e36 100755 --- a/Scripts/analyze_quotio.sh +++ b/Scripts/analyze_quotio.sh @@ -1,8 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash # Analyze quotio repository for interesting patterns and features # Usage: ./Scripts/analyze_quotio.sh [feature-area] -set -e +set -euo pipefail AREA=${1:-all} @@ -19,26 +19,53 @@ git fetch quotio 2>/dev/null || { git remote add quotio https://github.com/nguyenphutrong/quotio.git git fetch quotio } +remote_default_branch() { + local remote=$1 + local branch="" + local candidate + + branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true) + if [ -z "$branch" ]; then + branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true) + fi + if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then + echo "$branch" + return 0 + fi + + for candidate in main master; do + if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then + echo "$candidate" + return 0 + fi + done + + echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2 + exit 1 +} + +QUOTIO_BRANCH=$(remote_default_branch quotio) +QUOTIO_REF="quotio/${QUOTIO_BRANCH}" echo "" -echo -e "${GREEN}==> Quotio Repository Analysis${NC}" +echo -e "${GREEN}==> Quotio Repository Analysis (${QUOTIO_REF})${NC}" echo "" # Show recent activity echo -e "${BLUE}Recent Activity (last 30 days):${NC}" -git log --oneline --graph --remotes=quotio/main --since="30 days ago" | head -20 +git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true echo "" # Analyze file structure echo -e "${BLUE}File Structure:${NC}" -git ls-tree -r --name-only quotio/main | grep -E '\.(swift|md)$' | head -30 +git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -30 || true echo "" # Find interesting patterns based on area case $AREA in "providers"|"all") echo -e "${BLUE}Provider Implementations:${NC}" - git ls-tree -r --name-only quotio/main | grep -i provider | head -20 + git ls-tree -r --name-only "$QUOTIO_REF" | grep -i provider | head -20 || true echo "" ;; esac @@ -46,7 +73,7 @@ esac case $AREA in "ui"|"all") echo -e "${BLUE}UI Components:${NC}" - git ls-tree -r --name-only quotio/main | grep -iE '(view|ui|menu)' | head -20 + git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(view|ui|menu)' | head -20 || true echo "" ;; esac @@ -54,14 +81,14 @@ esac case $AREA in "auth"|"all") echo -e "${BLUE}Authentication/Session:${NC}" - git ls-tree -r --name-only quotio/main | grep -iE '(auth|session|cookie|login)' | head -20 + git ls-tree -r --name-only "$QUOTIO_REF" | grep -iE '(auth|session|cookie|login)' | head -20 || true echo "" ;; esac # Show commit messages for pattern analysis echo -e "${BLUE}Recent Commit Messages (for pattern analysis):${NC}" -git log --oneline quotio/main --since="60 days ago" | head -30 +git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true echo "" # Create analysis report @@ -70,20 +97,21 @@ cat > "$REPORT_FILE" << EOF # Quotio Analysis Report **Date:** $(date +%Y-%m-%d) **Purpose:** Identify patterns and features for CodexBar fork inspiration +**Source ref:** \`$QUOTIO_REF\` ## Recent Activity \`\`\` -$(git log --oneline --graph --remotes=quotio/main --since="30 days ago" | head -20) +$(git log --oneline --graph "$QUOTIO_REF" --since="30 days ago" | head -20 || true) \`\`\` ## File Structure \`\`\` -$(git ls-tree -r --name-only quotio/main | grep -E '\.(swift|md)$' | head -50) +$(git ls-tree -r --name-only "$QUOTIO_REF" | grep -E '\.(swift|md)$' | head -50 || true) \`\`\` ## Recent Commits \`\`\` -$(git log --oneline quotio/main --since="60 days ago" | head -30) +$(git log --oneline "$QUOTIO_REF" --since="60 days ago" | head -30 || true) \`\`\` ## Areas of Interest @@ -124,16 +152,15 @@ echo "" echo -e "${YELLOW}Next steps:${NC}" echo "" echo "1. View specific files:" -echo " ${GREEN}git show quotio/main:path/to/file${NC}" +echo " ${GREEN}git show $QUOTIO_REF:path/to/file${NC}" echo "" echo "2. Compare implementations:" -echo " ${GREEN}git diff main quotio/main -- path/to/similar/file${NC}" +echo " ${GREEN}git diff main $QUOTIO_REF -- path/to/similar/file${NC}" echo "" echo "3. Review commit details:" -echo " ${GREEN}git log -p quotio/main --since='30 days ago'${NC}" +echo " ${GREEN}git log -p $QUOTIO_REF --since='30 days ago'${NC}" echo "" echo "4. Document patterns in:" echo " ${GREEN}docs/QUOTIO_ANALYSIS.md${NC}" echo "" echo -e "${BLUE}Remember: Adapt patterns, don't copy code!${NC}" - diff --git a/Scripts/check-release-assets.sh b/Scripts/check-release-assets.sh index 4251ef6a2..89b0b54fb 100755 --- a/Scripts/check-release-assets.sh +++ b/Scripts/check-release-assets.sh @@ -5,6 +5,37 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) source "$HOME/Projects/agent-scripts/release/sparkle_lib.sh" TAG=${1:-$(git describe --tags --abbrev=0)} -ARTIFACT_PREFIX="CodexBar-" +ARTIFACT_PREFIX="CodexBar-macos-[A-Za-z0-9_+-]+-" check_assets "$TAG" "$ARTIFACT_PREFIX" + +VERSION=${TAG#v} +if gh --live release view "$TAG" --json assets --jq '.assets[].name' >/dev/null 2>&1; then + assets=$(gh --live release view "$TAG" --json assets --jq '.assets[].name') +else + assets=$(gh release view "$TAG" --json assets --jq '.assets[].name') +fi +missing=0 +for target in \ + macos-arm64 \ + macos-x86_64 \ + linux-aarch64 \ + linux-x86_64 +do + asset="CodexBarCLI-v${VERSION}-${target}.tar.gz" + checksum="${asset}.sha256" + if ! printf "%s\n" "$assets" | grep -Fxq "$asset"; then + echo "ERROR: CLI asset missing on release $TAG: $asset" >&2 + missing=1 + fi + if ! printf "%s\n" "$assets" | grep -Fxq "$checksum"; then + echo "ERROR: CLI checksum missing on release $TAG: $checksum" >&2 + missing=1 + fi +done + +if [[ "$missing" == "1" ]]; then + exit 1 +fi + +echo "Release $TAG has all CodexBarCLI tarballs and checksums." diff --git a/Scripts/check_upstreams.sh b/Scripts/check_upstreams.sh index a3ae64ee0..18dac1354 100755 --- a/Scripts/check_upstreams.sh +++ b/Scripts/check_upstreams.sh @@ -1,8 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash # Check for new changes in upstream repositories # Usage: ./Scripts/check_upstreams.sh [upstream|quotio|all] -set -e +set -euo pipefail TARGET=${1:-all} DAYS=${2:-7} @@ -33,19 +33,46 @@ fi echo "" +remote_default_branch() { + local remote=$1 + local branch="" + local candidate + + branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true) + if [ -z "$branch" ]; then + branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true) + fi + if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then + echo "$branch" + return 0 + fi + + for candidate in main master; do + if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then + echo "$candidate" + return 0 + fi + done + + echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2 + exit 1 +} + # Check upstream (steipete) if [ "$TARGET" = "all" ] || [ "$TARGET" = "upstream" ]; then echo -e "${BLUE}==> Upstream (steipete/CodexBar) changes:${NC}" + UPSTREAM_BRANCH=$(remote_default_branch upstream) + UPSTREAM_REF="upstream/${UPSTREAM_BRANCH}" - UPSTREAM_COUNT=$(git log --oneline main..upstream/main --no-merges 2>/dev/null | wc -l | tr -d ' ') + UPSTREAM_COUNT=$(git log --oneline "main..${UPSTREAM_REF}" --no-merges 2>/dev/null | wc -l | tr -d ' ') if [ "$UPSTREAM_COUNT" -gt 0 ]; then echo -e "${GREEN}Found $UPSTREAM_COUNT new commits${NC}" echo "" - git log --oneline --graph main..upstream/main --no-merges | head -20 + git log --oneline --graph "main..${UPSTREAM_REF}" --no-merges | head -20 || true echo "" echo -e "${YELLOW}Files changed:${NC}" - git diff --stat main..upstream/main | tail -20 + git diff --stat "main..${UPSTREAM_REF}" | tail -20 || true else echo -e "${GREEN}No new commits (up to date)${NC}" fi @@ -55,17 +82,19 @@ fi # Check quotio if [ "$TARGET" = "all" ] || [ "$TARGET" = "quotio" ]; then echo -e "${BLUE}==> Quotio changes (last $DAYS days):${NC}" + QUOTIO_BRANCH=$(remote_default_branch quotio) + QUOTIO_REF="quotio/${QUOTIO_BRANCH}" - QUOTIO_COUNT=$(git log --oneline --all --remotes=quotio/main --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ') + QUOTIO_COUNT=$(git log --oneline "$QUOTIO_REF" --since="$DAYS days ago" 2>/dev/null | wc -l | tr -d ' ') if [ "$QUOTIO_COUNT" -gt 0 ]; then echo -e "${GREEN}Found $QUOTIO_COUNT commits in last $DAYS days${NC}" echo "" - git log --oneline --graph --remotes=quotio/main --since="$DAYS days ago" | head -20 + git log --oneline --graph "$QUOTIO_REF" --since="$DAYS days ago" | head -20 || true echo "" echo -e "${YELLOW}Recent file changes:${NC}" # Show changes from last 10 commits - git diff --stat quotio/main~10..quotio/main 2>/dev/null | tail -20 || echo "Unable to show diff" + git diff --stat "${QUOTIO_REF}~10..${QUOTIO_REF}" 2>/dev/null | tail -20 || echo "Unable to show diff" else echo -e "${GREEN}No new commits in last $DAYS days${NC}" fi @@ -85,6 +114,5 @@ echo "" echo -e "${YELLOW}Next steps:${NC}" echo " Review upstream: ./Scripts/review_upstream.sh upstream" echo " Review quotio: ./Scripts/review_upstream.sh quotio" -echo " Detailed diff: git diff main..upstream/main" -echo " View quotio: git log -p quotio/main~10..quotio/main" - +echo " Detailed diff: git diff main../" +echo " View quotio: ./Scripts/analyze_quotio.sh" diff --git a/Scripts/ci_swift_test_by_suite.py b/Scripts/ci_swift_test_by_suite.py new file mode 100755 index 000000000..55e28ea07 --- /dev/null +++ b/Scripts/ci_swift_test_by_suite.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Run SwiftPM tests in suite shards so CI cannot hang inside one aggregate run.""" + +from __future__ import annotations + +import argparse +import os +import re +import signal +import subprocess +import sys +from collections.abc import Iterable + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--group-size", type=int, default=12) + parser.add_argument("--timeout", type=int, default=180) + parser.add_argument("--limit-groups", type=int) + parser.add_argument("--list-only", action="store_true") + return parser.parse_args() + + +def run_command(command: list[str], timeout: int | None = None) -> int: + print(f"+ {' '.join(command)}", flush=True) + process = subprocess.Popen(command, start_new_session=True) + try: + return process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + print(f"::warning::Command timed out after {timeout}s: {' '.join(command)}", flush=True) + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + return 124 + + +def swift_test_list() -> list[str]: + result = subprocess.run(["swift", "test", "list"], check=True, capture_output=True, text=True) + suites: set[str] = set() + for line in result.stdout.splitlines(): + if "/" not in line: + continue + suite = line.split("/", 1)[0] + if "." not in suite: + continue + suites.add(suite) + return sorted(suites) + + +def chunks(items: list[str], size: int) -> Iterable[list[str]]: + for index in range(0, len(items), size): + yield items[index : index + size] + + +def prioritized_suites(suites: list[str]) -> list[str]: + priority = ["CodexBarTests.CLIEntryTests"] + ordered = [suite for suite in priority if suite in suites] + ordered.extend(suite for suite in suites if suite not in priority) + return ordered + + +def filtered_suites_for_environment(suites: list[str]) -> list[str]: + if os.environ.get("GITHUB_ACTIONS") != "true" or sys.platform != "darwin": + return suites + + # SwiftPM hangs before suite output for this executable-target suite on the Intel macOS runner. + # Linux CI still runs it in the full Swift test lane, and local macOS runs it directly. + skipped = {"CodexBarTests.CLIEntryTests"} + filtered = [suite for suite in suites if suite not in skipped] + if len(filtered) != len(suites): + print(f"Skipping macOS CI-only suites: {', '.join(sorted(skipped))}", flush=True) + return filtered + + +def filter_for(suites: list[str]) -> str: + escaped = [re.escape(suite) for suite in suites] + return rf"^({'|'.join(escaped)})/" + + +def run_group(suites: list[str], timeout: int) -> int: + return run_command(["swift", "test", "--no-parallel", "--filter", filter_for(suites)], timeout=timeout) + + +def main() -> int: + args = parse_args() + if args.group_size < 1: + print("--group-size must be positive", file=sys.stderr) + return 2 + + suites = prioritized_suites(filtered_suites_for_environment(swift_test_list())) + print(f"Discovered {len(suites)} test suites", flush=True) + if args.list_only: + for suite in suites: + print(suite) + return 0 + + suite_groups = list(chunks(suites, args.group_size)) + if args.limit_groups is not None: + suite_groups = suite_groups[: args.limit_groups] + + for group_index, group in enumerate(suite_groups, start=1): + print( + f"::group::Swift test shard {group_index}/{len(suite_groups)} " + f"({len(group)} suites)", + flush=True, + ) + result = run_group(group, args.timeout) + print("::endgroup::", flush=True) + if result == 0: + continue + if result != 124 or len(group) == 1: + return result + + print(f"Shard {group_index} timed out; retrying suites one at a time", flush=True) + for suite in group: + print(f"::group::Swift test retry {suite}", flush=True) + retry_result = run_group([suite], args.timeout) + print("::endgroup::", flush=True) + if retry_result != 0: + return retry_result + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh index 12a39996b..980afcf28 100755 --- a/Scripts/compile_and_run.sh +++ b/Scripts/compile_and_run.sh @@ -64,13 +64,47 @@ has_signing_identity() { security find-identity -p codesigning -v 2>/dev/null | grep -F "${identity}" >/dev/null 2>&1 } +detect_codesigning_identity() { + local preferred_prefixes=( + "Developer ID Application:" + "Apple Development:" + "Apple Distribution:" + ) + local prefix + local identities + identities="$(security find-identity -p codesigning -v 2>/dev/null || true)" + for prefix in "${preferred_prefixes[@]}"; do + awk -v prefix="${prefix}" ' + index($0, "\"" prefix) { + sub(/^[^\"]*\"/, "") + sub(/\".*$/, "") + print + exit + } + ' <<<"${identities}" + done | sed -n '1p' +} + +export_team_id_from_identity() { + local identity="${1:-}" + if [[ -n "${APP_TEAM_ID:-}" || -z "${identity}" ]]; then + return + fi + if [[ "${identity}" =~ \(([A-Z0-9]{10})\)$ ]]; then + APP_TEAM_ID="${BASH_REMATCH[1]}" + export APP_TEAM_ID + fi +} + resolve_signing_mode() { if [[ -n "${SIGNING_MODE}" ]]; then + export_team_id_from_identity "${APP_IDENTITY:-}" return fi if [[ -n "${APP_IDENTITY:-}" ]]; then if has_signing_identity "${APP_IDENTITY}"; then + export_team_id_from_identity "${APP_IDENTITY}" SIGNING_MODE="identity" return fi @@ -87,11 +121,21 @@ resolve_signing_mode() { if has_signing_identity "${candidate}"; then APP_IDENTITY="${candidate}" export APP_IDENTITY + export_team_id_from_identity "${APP_IDENTITY}" SIGNING_MODE="identity" return fi done + candidate="$(detect_codesigning_identity)" + if [[ -n "${candidate}" ]]; then + APP_IDENTITY="${candidate}" + export APP_IDENTITY + export_team_id_from_identity "${APP_IDENTITY}" + SIGNING_MODE="identity" + return + fi + SIGNING_MODE="adhoc" } @@ -242,13 +286,17 @@ ARCHES_VALUE="${HOST_ARCH}" if [[ -n "${RELEASE_ARCHES}" ]]; then ARCHES_VALUE="${RELEASE_ARCHES}" fi +PACKAGE_ENV=( + CODEXBAR_WIDGET_METADATA_MODE="${CODEXBAR_WIDGET_METADATA_MODE:-skip}" + ARCHES="${ARCHES_VALUE}" +) if [[ "${DEBUG_LLDB}" == "1" ]]; then - run_step "package app" env CODEXBAR_ALLOW_LLDB=1 ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" debug + run_step "package app" env CODEXBAR_ALLOW_LLDB=1 "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" debug else if [[ -n "${SIGNING_MODE}" ]]; then - run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" + run_step "package app" env CODEXBAR_SIGNING="${SIGNING_MODE}" "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" else - run_step "package app" env ARCHES="${ARCHES_VALUE}" "${ROOT_DIR}/Scripts/package_app.sh" + run_step "package app" env "${PACKAGE_ENV[@]}" "${ROOT_DIR}/Scripts/package_app.sh" fi fi diff --git a/Scripts/generate-llms.mjs b/Scripts/generate-llms.mjs new file mode 100755 index 000000000..16390124f --- /dev/null +++ b/Scripts/generate-llms.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const docsDir = path.join(repoRoot, "docs"); +const cname = fs.readFileSync(path.join(docsDir, "CNAME"), "utf8").trim(); +const origin = "https://" + cname; +const productName = "CodexBar"; +const productDescription = "CodexBar shows OpenAI Codex and Claude Code usage limits in the macOS menu bar."; +const source = "https://github.com/steipete/CodexBar"; + +const pages = allHtml(docsDir) + .map((file) => { + const rel = path.relative(docsDir, file).replaceAll(path.sep, "/"); + if (rel === "404.html" || rel === "social.html") return null; + const html = fs.readFileSync(file, "utf8"); + return { + rel, + title: textContent(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]) || titleize(path.basename(rel, ".html")), + description: attr(html.match(/]*>/i)?.[1] || ""), + }; + }) + .filter(Boolean) + .sort((a, b) => (a.rel === "index.html" ? -1 : b.rel === "index.html" ? 1 : a.rel.localeCompare(b.rel))); + +const lines = [ + "# " + productName, + "", + productDescription, + "", + "Canonical documentation:", + ...pages.map((page) => "- " + page.title + ": " + pageUrl(page.rel) + (page.description ? " - " + page.description : "")), + "", + "Source: " + source, + "", + "Guidance for agents:", + "- Prefer the canonical documentation URLs above over README excerpts or package metadata.", + "- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.", + "", +]; + +fs.writeFileSync(path.join(docsDir, "llms.txt"), lines.join("\n"), "utf8"); +console.log("wrote " + path.relative(repoRoot, path.join(docsDir, "llms.txt"))); + +function allHtml(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.name === "node_modules" || entry.name.startsWith(".")) return []; + if (entry.isDirectory()) return allHtml(full); + return entry.name.endsWith(".html") ? [full] : []; + }); +} + +function pageUrl(rel) { + return rel === "index.html" ? origin + "/" : origin + "/" + rel; +} + +function textContent(value) { + return attr(value || "").replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(); +} + +function attr(value) { + return String(value || "") + .replace(/—/g, "-") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/'/g, "'") + .replace(/"/g, '"') + .trim(); +} + +function titleize(input) { + return input.replaceAll("-", " ").replace(/\b\w/g, (m) => m.toUpperCase()); +} diff --git a/Scripts/make_appcast.sh b/Scripts/make_appcast.sh index bcf6c06ac..5109a1da9 100755 --- a/Scripts/make_appcast.sh +++ b/Scripts/make_appcast.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) ZIP=${1:? -"Usage: $0 CodexBar-.zip"} +"Usage: $0 CodexBar-macos--.zip"} FEED_URL=${2:-"https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml"} PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-} SPARKLE_CHANNEL=${SPARKLE_CHANNEL:-} @@ -21,8 +21,8 @@ ZIP_NAME=$(basename "$ZIP") ZIP_BASE="${ZIP_NAME%.zip}" VERSION=${SPARKLE_RELEASE_VERSION:-} if [[ -z "$VERSION" ]]; then - if [[ "$ZIP_NAME" =~ ^CodexBar-([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then - VERSION="${BASH_REMATCH[1]}" + if [[ "$ZIP_NAME" =~ ^CodexBar-(macos-[A-Za-z0-9_+-]+-)?([0-9]+(\.[0-9]+){1,2}([-.][^.]*)?)\.zip$ ]]; then + VERSION="${BASH_REMATCH[2]}" else echo "Could not infer version from $ZIP_NAME; set SPARKLE_RELEASE_VERSION." >&2 exit 1 diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index ac8842dab..afd717880 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -5,6 +5,7 @@ ALLOW_LLDB=${CODEXBAR_ALLOW_LLDB:-0} SIGNING_MODE=${CODEXBAR_SIGNING:-} ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" +LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') # Load version info source "$ROOT/version.env" @@ -100,6 +101,7 @@ 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 @@ -115,6 +117,29 @@ generate_widget_appintents_metadata() { 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" @@ -135,24 +160,74 @@ generate_widget_appintents_metadata() { toolchain_dir=$(dirname "$(dirname "$(dirname "$swiftc_path")")") xcode_version=$(xcodebuild -version | awk '/Build version/ { print $3 }') - rm -rf "$derived_dir" + 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" \ - build >/dev/null + -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 - echo "ERROR: Missing App Intents metadata inputs for CodexBarWidget." >&2 - exit 1 + 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 - echo "ERROR: Missing App Intents const-values outputs for CodexBarWidget." >&2 - exit 1 + 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" @@ -173,8 +248,8 @@ generate_widget_appintents_metadata() { --force >/dev/null if [[ ! -f "$widget_resources_dir/Metadata.appintents/extract.actionsdata" ]]; then - echo "ERROR: Failed to generate App Intents metadata for CodexBarWidget." >&2 - exit 1 + widget_metadata_warn_or_fail "Failed to generate App Intents metadata for CodexBarWidget" + return 0 fi } @@ -188,8 +263,10 @@ for ARCH in "${ARCH_LIST[@]}"; do swift build -c "$CONF" --arch "$ARCH" done -APP="$ROOT/CodexBar.app" -rm -rf "$APP" +APP_FINAL="$ROOT/CodexBar.app" +APP_STAGE="$ROOT/.build/package/CodexBar.app" +rm -rf "$APP_STAGE" +APP="$APP_STAGE" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" mkdir -p "$APP/Contents/Helpers" "$APP/Contents/PlugIns" @@ -203,7 +280,6 @@ fi BUNDLE_ID="com.steipete.codexbar" FEED_URL="https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml" AUTO_CHECKS=true -LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') if [[ "$LOWER_CONF" == "debug" ]]; then BUNDLE_ID="com.steipete.codexbar.debug" FEED_URL="" @@ -480,4 +556,7 @@ codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" +rm -rf "$APP_FINAL" +mv "$APP" "$APP_FINAL" +APP="$APP_FINAL" echo "Created $APP" diff --git a/Scripts/release.sh b/Scripts/release.sh index 5aa9f6b83..254e9a2c5 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -5,11 +5,15 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" source "$ROOT/version.env" +source "$ROOT/Scripts/release_artifacts.sh" source "$HOME/Projects/agent-scripts/release/sparkle_lib.sh" APPCAST="$ROOT/appcast.xml" APP_NAME="CodexBar" -ARTIFACT_PREFIX="CodexBar-" +ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} +APP_ZIP=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") +DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") +ARTIFACT_PREFIX="CodexBar-macos-[A-Za-z0-9_+-]+-" BUNDLE_ID="com.steipete.codexbar" TAG="v${MARKETING_VERSION}" @@ -40,13 +44,13 @@ trap 'rm -f "$KEY_FILE" "$NOTES_FILE"' EXIT git tag -s -f -m "${APP_NAME} ${MARKETING_VERSION}" "$TAG" git push -f origin "$TAG" -gh release create "$TAG" ${APP_NAME}-${MARKETING_VERSION}.zip ${APP_NAME}-${MARKETING_VERSION}.dSYM.zip \ +gh release create "$TAG" "$APP_ZIP" "$DSYM_ZIP" \ --title "${APP_NAME} ${MARKETING_VERSION}" \ --notes-file "$NOTES_FILE" SPARKLE_PRIVATE_KEY_FILE="$KEY_FILE" \ "$ROOT/Scripts/make_appcast.sh" \ - "${APP_NAME}-${MARKETING_VERSION}.zip" \ + "$APP_ZIP" \ "https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml" verify_appcast_entry "$APPCAST" "$MARKETING_VERSION" "$KEY_FILE" diff --git a/Scripts/release_artifacts.sh b/Scripts/release_artifacts.sh new file mode 100755 index 000000000..bc065c1de --- /dev/null +++ b/Scripts/release_artifacts.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +codexbar_release_arch_label() { + local raw="${1:-arm64 x86_64}" + local normalized + local has_arm64=0 + local has_x86_64=0 + local arch + + normalized=$(printf "%s" "$raw" | tr ',' ' ') + for arch in $normalized; do + case "$arch" in + arm64) has_arm64=1 ;; + x86_64) has_x86_64=1 ;; + esac + done + + if [[ "$has_arm64" == "1" && "$has_x86_64" == "1" ]]; then + printf "macos-universal" + return + fi + if [[ "$has_arm64" == "1" ]]; then + printf "macos-arm64" + return + fi + if [[ "$has_x86_64" == "1" ]]; then + printf "macos-x86_64" + return + fi + + printf "macos-%s" "$(printf "%s" "$normalized" | tr ' ' '+')" +} + +codexbar_app_zip_name() { + local version=$1 + local arches="${2:-arm64 x86_64}" + printf "CodexBar-%s-%s.zip" "$(codexbar_release_arch_label "$arches")" "$version" +} + +codexbar_dsym_zip_name() { + local version=$1 + local arches="${2:-arm64 x86_64}" + printf "CodexBar-%s-%s.dSYM.zip" "$(codexbar_release_arch_label "$arches")" "$version" +} diff --git a/Scripts/review_upstream.sh b/Scripts/review_upstream.sh index cf1f37688..57b634eb6 100755 --- a/Scripts/review_upstream.sh +++ b/Scripts/review_upstream.sh @@ -1,8 +1,8 @@ -#!/bin/bash +#!/usr/bin/env bash # Create a review branch for upstream changes # Usage: ./Scripts/review_upstream.sh [upstream|quotio] -set -e +set -euo pipefail UPSTREAM=${1:-upstream} DATE=$(date +%Y%m%d) @@ -21,20 +21,81 @@ if [ "$UPSTREAM" != "upstream" ] && [ "$UPSTREAM" != "quotio" ]; then exit 1 fi -echo -e "${BLUE}==> Creating review branch for $UPSTREAM...${NC}" -git checkout main -git checkout -b "$BRANCH_NAME" +ensure_remote() { + local remote=$1 + local url=$2 + local origin_url + + if git remote get-url "$remote" >/dev/null 2>&1; then + echo "$remote" + return 0 + fi + + if [ "$remote" = "upstream" ] && git remote get-url origin >/dev/null 2>&1; then + origin_url=$(git remote get-url origin) + case "$origin_url" in + https://github.com/steipete/CodexBar|https://github.com/steipete/CodexBar.git|git@github.com:steipete/CodexBar.git) + echo -e "${YELLOW}Remote 'upstream' missing; using origin for steipete/CodexBar.${NC}" >&2 + echo "origin" + return 0 + ;; + *) + echo -e "${YELLOW}Remote 'upstream' missing; origin is not steipete/CodexBar, adding upstream.${NC}" >&2 + ;; + esac + fi + + echo -e "${YELLOW}Adding $remote remote...${NC}" >&2 + git remote add "$remote" "$url" + echo "$remote" +} + +remote_default_branch() { + local remote=$1 + local branch="" + local candidate + + branch=$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s#^${remote}/##" || true) + if [ -z "$branch" ]; then + branch=$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}' || true) + fi + if [ -n "$branch" ] && git rev-parse --verify -q "${remote}/${branch}" >/dev/null; then + echo "$branch" + return 0 + fi + + for candidate in main master; do + if git rev-parse --verify -q "${remote}/${candidate}" >/dev/null; then + echo "$candidate" + return 0 + fi + done + + echo -e "${RED}Error: Could not resolve default branch for remote '$remote'.${NC}" >&2 + exit 1 +} + +case "$UPSTREAM" in + upstream) REMOTE=$(ensure_remote upstream "https://github.com/steipete/CodexBar.git") ;; + quotio) REMOTE=$(ensure_remote quotio "https://github.com/nguyenphutrong/quotio.git") ;; +esac echo -e "${BLUE}==> Fetching latest from $UPSTREAM...${NC}" -git fetch "$UPSTREAM" +git fetch "$REMOTE" --prune +REMOTE_BRANCH=$(remote_default_branch "$REMOTE") +REMOTE_REF="${REMOTE}/${REMOTE_BRANCH}" + +echo -e "${BLUE}==> Creating review branch for $UPSTREAM (${REMOTE_REF})...${NC}" +git switch main +git switch -c "$BRANCH_NAME" echo "" echo -e "${GREEN}==> Commits to review:${NC}" -git log --oneline --graph main.."$UPSTREAM"/main | head -30 +git log --oneline --graph "main..${REMOTE_REF}" | head -30 || true echo "" echo -e "${GREEN}==> File changes summary:${NC}" -git diff --stat main.."$UPSTREAM"/main +git diff --stat "main..${REMOTE_REF}" echo "" echo -e "${YELLOW}==> Review branch created: $BRANCH_NAME${NC}" @@ -42,16 +103,16 @@ echo "" echo -e "${BLUE}Next steps:${NC}" echo "" echo "1. Review commits in detail:" -echo " ${GREEN}git log -p main..$UPSTREAM/main${NC}" +echo " ${GREEN}git log -p main..$REMOTE_REF${NC}" echo "" echo "2. View specific files:" -echo " ${GREEN}git show $UPSTREAM/main:path/to/file${NC}" +echo " ${GREEN}git show $REMOTE_REF:path/to/file${NC}" echo "" echo "3. Cherry-pick specific commits:" echo " ${GREEN}git cherry-pick ${NC}" echo "" echo "4. Or merge all changes:" -echo " ${GREEN}git merge $UPSTREAM/main${NC}" +echo " ${GREEN}git merge $REMOTE_REF${NC}" echo "" echo "5. Test thoroughly:" echo " ${GREEN}./Scripts/compile_and_run.sh${NC}" @@ -68,10 +129,9 @@ LOG_FILE="upstream-review-${UPSTREAM}-${DATE}.txt" echo "=== Upstream Review: $UPSTREAM @ $DATE ===" > "$LOG_FILE" echo "" >> "$LOG_FILE" echo "Commits:" >> "$LOG_FILE" -git log --oneline main.."$UPSTREAM"/main >> "$LOG_FILE" +git log --oneline "main..${REMOTE_REF}" >> "$LOG_FILE" echo "" >> "$LOG_FILE" echo "File changes:" >> "$LOG_FILE" -git diff --stat main.."$UPSTREAM"/main >> "$LOG_FILE" +git diff --stat "main..${REMOTE_REF}" >> "$LOG_FILE" echo -e "${GREEN}Review log saved to: $LOG_FILE${NC}" - diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index 6a6c87072..508efd465 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -6,8 +6,12 @@ APP_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" APP_BUNDLE="CodexBar.app" ROOT=$(cd "$(dirname "$0")/.." && pwd) source "$ROOT/version.env" -ZIP_NAME="${APP_NAME}-${MARKETING_VERSION}.zip" -DSYM_ZIP="${APP_NAME}-${MARKETING_VERSION}.dSYM.zip" +source "$ROOT/Scripts/release_artifacts.sh" + +# Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). +ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} +ZIP_NAME=$(codexbar_app_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") +DSYM_ZIP=$(codexbar_dsym_zip_name "$MARKETING_VERSION" "$ARCHES_VALUE") if [[ -z "${APP_STORE_CONNECT_API_KEY_P8:-}" || -z "${APP_STORE_CONNECT_KEY_ID:-}" || -z "${APP_STORE_CONNECT_ISSUER_ID:-}" ]]; then echo "Missing APP_STORE_CONNECT_* env vars (API key, key id, issuer id)." >&2 @@ -30,13 +34,11 @@ fi echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 trap 'rm -f /tmp/codexbar-api-key.p8 /tmp/${APP_NAME}Notarize.zip' EXIT -# Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). -ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} ARCH_LIST=( ${ARCHES_VALUE} ) for ARCH in "${ARCH_LIST[@]}"; do swift build -c release --arch "$ARCH" done -ARCHES="${ARCHES_VALUE}" ./Scripts/package_app.sh release +CODEXBAR_WIDGET_METADATA_MODE=required ARCHES="${ARCHES_VALUE}" ./Scripts/package_app.sh release ENTITLEMENTS_DIR="$ROOT/.build/entitlements" APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements" @@ -94,8 +96,10 @@ if [[ ! -d "$DSYM_PATH" ]]; then exit 1 fi if [[ ${#ARCH_LIST[@]} -gt 1 ]]; then - MERGED_DSYM="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM-universal" - rm -rf "$MERGED_DSYM" + MERGED_DSYM_ROOT="${PREFERRED_ARCH_DIR}/${APP_NAME}.dSYM-universal" + MERGED_DSYM="${MERGED_DSYM_ROOT}/${APP_NAME}.dSYM" + rm -rf "$MERGED_DSYM_ROOT" + mkdir -p "$MERGED_DSYM_ROOT" cp -R "$DSYM_PATH" "$MERGED_DSYM" DWARF_PATH="${MERGED_DSYM}/Contents/Resources/DWARF/${APP_NAME}" BINARIES=() diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 6bd3bc55a..126935940 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -6,35 +6,87 @@ import Foundation final class AppNotifications { static let shared = AppNotifications() - private let centerProvider: @Sendable () -> UNUserNotificationCenter + private let authorizationStatusProvider: @Sendable () async -> UNAuthorizationStatus? + private let authorizationRequester: @Sendable () async -> Bool + private let requestPoster: @Sendable (UNNotificationRequest) async throws -> Void + private let soundPlayer: @MainActor @Sendable (NotificationSoundOption, Double) -> Bool + private let allowsPostingWhenRunningUnderTests: Bool private let logger = CodexBarLog.logger(LogCategories.notifications) private var authorizationTask: Task? - init(centerProvider: @escaping @Sendable () -> UNUserNotificationCenter = { UNUserNotificationCenter.current() }) { - self.centerProvider = centerProvider + init( + authorizationStatusProvider: @escaping @Sendable () async -> UNAuthorizationStatus? = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + }, + authorizationRequester: @escaping @Sendable () async -> Bool = { + let center = UNUserNotificationCenter.current() + return await withCheckedContinuation { continuation in + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + continuation.resume(returning: granted) + } + } + }, + requestPoster: @escaping @Sendable (UNNotificationRequest) async throws -> Void = { request in + try await UNUserNotificationCenter.current().add(request) + }, + soundPlayer: @escaping @MainActor @Sendable (NotificationSoundOption, Double) -> Bool = { sound, volume in + NotificationSoundPlayer.play(sound, volume: volume) + }, + allowsPostingWhenRunningUnderTests: Bool = false) + { + self.authorizationStatusProvider = authorizationStatusProvider + self.authorizationRequester = authorizationRequester + self.requestPoster = requestPoster + self.soundPlayer = soundPlayer + self.allowsPostingWhenRunningUnderTests = allowsPostingWhenRunningUnderTests } - func requestAuthorizationOnStartup() { - guard !Self.isRunningUnderTests else { return } + func requestAuthorizationOnStartup(notificationsEnabled: Bool = true) { + guard notificationsEnabled, self.canPostInCurrentEnvironment else { return } _ = self.ensureAuthorizationTask() } - func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) { - guard !Self.isRunningUnderTests else { return } - let center = self.centerProvider() - let logger = self.logger + @discardableResult + func post( + idPrefix: String, + title: String, + body: String, + badge: NSNumber? = nil, + soundEnabled: Bool = true, + event: AppNotificationEvent? = nil, + provider: String? = nil, + notificationsEnabled: Bool = true, + notificationVolume: Double = 1.0, + settings: NotificationDeliverySettings? = nil) -> Task? + { + guard self.canPostInCurrentEnvironment else { return nil } + + return Task { @MainActor in + let deliverySettings = settings ?? .localDefault + guard notificationsEnabled, deliverySettings.enabled else { + self.logger.debug( + "disabled; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) + return + } - Task { @MainActor in let granted = await self.ensureAuthorized() guard granted else { - logger.debug("not authorized; skipping post", metadata: ["prefix": idPrefix]) + self.logger.debug( + "not authorized; skipping notification", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) return } let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = .default + content.sound = soundEnabled && deliverySettings.sound == .systemDefault ? .default : nil content.badge = badge let request = UNNotificationRequest( @@ -42,12 +94,22 @@ final class AppNotifications { content: content, trigger: nil) - logger.info("posting", metadata: ["prefix": idPrefix]) + self.logger.info( + "posting", + metadata: self.metadata(event: event, idPrefix: idPrefix, provider: provider)) do { - try await center.add(request) + try await self.requestPoster(request) + self.playSoundIfNeeded( + event: event, + idPrefix: idPrefix, + provider: provider, + settings: deliverySettings, + soundEnabled: soundEnabled, + notificationVolume: notificationVolume) } catch { - let errorText = String(describing: error) - logger.error("failed to post", metadata: ["prefix": idPrefix, "error": errorText]) + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["error"] = "\(error)" + self.logger.error("failed to post", metadata: metadata) } } } @@ -68,7 +130,7 @@ final class AppNotifications { } private func requestAuthorization() async -> Bool { - if let existing = await self.notificationAuthorizationStatus() { + if let existing = await self.authorizationStatusProvider() { if existing == .authorized || existing == .provisional { return true } @@ -77,21 +139,43 @@ final class AppNotifications { } } - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in - continuation.resume(returning: granted) - } + return await self.authorizationRequester() + } + + private var canPostInCurrentEnvironment: Bool { + self.allowsPostingWhenRunningUnderTests || !Self.isRunningUnderTests + } + + private func playSoundIfNeeded( + event: AppNotificationEvent?, + idPrefix: String, + provider: String?, + settings: NotificationDeliverySettings, + soundEnabled: Bool, + notificationVolume: Double) + { + guard soundEnabled else { return } + guard settings.sound != .none, settings.sound != .systemDefault else { return } + var metadata = self.metadata(event: event, idPrefix: idPrefix, provider: provider) + metadata["sound"] = settings.sound.rawValue + metadata["volume"] = "\(notificationVolume)" + + if self.soundPlayer(settings.sound, notificationVolume) { + self.logger.info("played sound", metadata: metadata) + } else { + self.logger.error("failed to play sound", metadata: metadata) } } - private func notificationAuthorizationStatus() async -> UNAuthorizationStatus? { - let center = self.centerProvider() - return await withCheckedContinuation { continuation in - center.getNotificationSettings { settings in - continuation.resume(returning: settings.authorizationStatus) - } + private func metadata(event: AppNotificationEvent?, idPrefix: String, provider: String?) -> [String: String] { + var metadata = [ + "event": event?.rawValue ?? "legacy", + "prefix": idPrefix, + ] + if let provider = Self.normalizedProvider(provider) { + metadata["provider"] = provider } + return metadata } private static var isRunningUnderTests: Bool { @@ -106,4 +190,10 @@ final class AppNotifications { if env["SWIFT_TESTING"] != nil { return true } return NSClassFromString("XCTestCase") != nil } + + private nonisolated static func normalizedProvider(_ provider: String?) -> String? { + let trimmed = provider?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed + } } diff --git a/Sources/CodexBar/ClickToCopyOverlay.swift b/Sources/CodexBar/ClickToCopyOverlay.swift new file mode 100644 index 000000000..114602854 --- /dev/null +++ b/Sources/CodexBar/ClickToCopyOverlay.swift @@ -0,0 +1,40 @@ +import AppKit +import SwiftUI + +struct ClickToCopyOverlay: NSViewRepresentable { + let copyText: String + + func makeNSView(context: Context) -> ClickToCopyView { + ClickToCopyView(copyText: self.copyText) + } + + func updateNSView(_ nsView: ClickToCopyView, context: Context) { + nsView.copyText = self.copyText + } +} + +final class ClickToCopyView: NSView { + var copyText: String + + init(copyText: String) { + self.copyText = copyText + super.init(frame: .zero) + self.wantsLayer = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + _ = event + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(self.copyText, forType: .string) + } +} diff --git a/Sources/CodexBar/CodexAccountMenuPresentation.swift b/Sources/CodexBar/CodexAccountMenuPresentation.swift new file mode 100644 index 000000000..5628ee26e --- /dev/null +++ b/Sources/CodexBar/CodexAccountMenuPresentation.swift @@ -0,0 +1,180 @@ +import CodexBarCore +import Foundation + +enum CodexAccountHealth: Equatable { + case ok + case needsReauth + case workspaceDeactivated + case missingAuth + case unavailable + + var label: String? { + switch self { + case .ok: + nil + case .needsReauth: + "Needs re-auth" + case .workspaceDeactivated: + "Workspace deactivated" + case .missingAuth: + "Missing auth" + case .unavailable: + "Unavailable" + } + } + + static func status(for account: CodexVisibleAccount, error: String?) -> CodexAccountHealth { + if let error { + return self.status(forError: error) + } + if account.authenticationHealthLabel != nil { + return .missingAuth + } + return .ok + } + + static func status(forError error: String) -> CodexAccountHealth { + let normalized = error.lowercased() + if normalized.contains("deactivated") { + return .workspaceDeactivated + } + if normalized.contains("expired") || + normalized.contains("revoked") || + normalized.contains("unauthorized") || + normalized.contains("401") + { + return .needsReauth + } + if normalized.contains("missing"), normalized.contains("auth") { + return .missingAuth + } + return .unavailable + } +} + +enum CodexAccountPresentationOrdering { + static func orderedAccounts( + _ accounts: [CodexVisibleAccount], + snapshots: [CodexAccountUsageSnapshot], + activeVisibleAccountID: String?) + -> [CodexVisibleAccount] + { + guard accounts.count > 1 else { return accounts } + let snapshotByID = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) }) + let rankedAccounts = accounts.enumerated().map { index, account in + RankedAccount( + account: account, + rank: Rank( + account: account, + snapshot: snapshotByID[account.id], + activeVisibleAccountID: activeVisibleAccountID, + originalIndex: index)) + } + let grouped = Dictionary(grouping: rankedAccounts, by: { Self.workspaceSortKey(for: $0.account) }) + return grouped.values.sorted { lhs, rhs in + (lhs.map(\.rank).min() ?? .last) < (rhs.map(\.rank).min() ?? .last) + }.flatMap { group in + group.sorted { lhs, rhs in lhs.rank < rhs.rank }.map(\.account) + } + } + + private struct RankedAccount { + let account: CodexVisibleAccount + let rank: Rank + } + + private struct Rank: Comparable { + static let last = Rank(bucket: Int.max, availabilityScore: -.greatestFiniteMagnitude, originalIndex: Int.max) + + let bucket: Int + let availabilityScore: Double + let displaySort: String + let originalIndex: Int + + private init(bucket: Int, availabilityScore: Double, originalIndex: Int) { + self.bucket = bucket + self.availabilityScore = availabilityScore + self.displaySort = "" + self.originalIndex = originalIndex + } + + init( + account: CodexVisibleAccount, + snapshot: CodexAccountUsageSnapshot?, + activeVisibleAccountID: String?, + originalIndex: Int) + { + self.originalIndex = originalIndex + self.displaySort = account.menuDisplayName.lowercased() + + if account.id == activeVisibleAccountID { + self.bucket = 0 + } else { + let health = CodexAccountHealth.status(for: account, error: snapshot?.error) + if health != .ok { + self.bucket = health == .missingAuth ? 4 : 3 + } else if let availability = Self.availability(snapshot?.snapshot), availability <= 0 { + self.bucket = 2 + } else { + self.bucket = 1 + } + } + self.availabilityScore = Self.availability(snapshot?.snapshot) ?? -1 + } + + static func < (lhs: Rank, rhs: Rank) -> Bool { + if lhs.bucket != rhs.bucket { return lhs.bucket < rhs.bucket } + if lhs.availabilityScore != rhs.availabilityScore { + return lhs.availabilityScore > rhs.availabilityScore + } + if lhs.displaySort != rhs.displaySort { return lhs.displaySort < rhs.displaySort } + return lhs.originalIndex < rhs.originalIndex + } + + private static func availability(_ snapshot: UsageSnapshot?) -> Double? { + guard let snapshot else { return nil } + let session = snapshot.primary?.remainingPercent + let weekly = snapshot.secondary?.remainingPercent + return switch (session, weekly) { + case let (.some(session), .some(weekly)): + min(session, weekly) + case let (.some(session), .none): + session + case let (.none, .some(weekly)): + weekly + case (.none, .none): + nil + } + } + } + + private static func workspaceSortKey(for account: CodexVisibleAccount) -> String { + if let workspaceAccountID = account.workspaceAccountID, !workspaceAccountID.isEmpty { + return workspaceAccountID.lowercased() + } + return account.menuWorkspaceLabel?.lowercased() ?? "personal" + } +} + +struct CodexAccountWorkspaceSection: Equatable { + let title: String + let accounts: [CodexVisibleAccount] +} + +extension [CodexVisibleAccount] { + func codexWorkspaceSections() -> [CodexAccountWorkspaceSection] { + guard !self.isEmpty else { return [] } + var sections: [CodexAccountWorkspaceSection] = [] + for account in self { + let title = account.menuWorkspaceLabel ?? "Personal" + if let index = sections.firstIndex(where: { $0.title == title }) { + var accounts = sections[index].accounts + accounts.append(account) + sections[index] = CodexAccountWorkspaceSection(title: title, accounts: accounts) + } else { + sections.append(CodexAccountWorkspaceSection(title: title, accounts: [account])) + } + } + return sections + } +} diff --git a/Sources/CodexBar/CodexAccountPromotionExecution.swift b/Sources/CodexBar/CodexAccountPromotionExecution.swift index 25b8a27c7..7ca58658f 100644 --- a/Sources/CodexBar/CodexAccountPromotionExecution.swift +++ b/Sources/CodexBar/CodexAccountPromotionExecution.swift @@ -103,6 +103,7 @@ struct CodexDisplacedLivePreservationExecutor { providerAccountID: liveAuthIdentity.providerAccountID, workspaceLabel: liveAuthIdentity.workspaceLabel, workspaceAccountID: liveAuthIdentity.workspaceAccountID, + authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData), managedHomePath: importedHomeURL.path, createdAt: now, updatedAt: now, @@ -157,6 +158,7 @@ struct CodexDisplacedLivePreservationExecutor { providerAccountID: importedAccount.account.providerAccountID, workspaceLabel: importedAccount.account.workspaceLabel, workspaceAccountID: importedAccount.account.workspaceAccountID, + authFingerprint: importedAccount.account.authFingerprint, managedHomePath: importedAccount.homeURL.path, createdAt: existingManagedAccount.createdAt, updatedAt: importedAccount.account.updatedAt, @@ -225,6 +227,7 @@ struct CodexDisplacedLivePreservationExecutor { providerAccountID: liveAuthIdentity.providerAccountID ?? persistedManagedAccount.providerAccountID, workspaceLabel: liveAuthIdentity.workspaceLabel ?? persistedManagedAccount.workspaceLabel, workspaceAccountID: liveAuthIdentity.workspaceAccountID ?? persistedManagedAccount.workspaceAccountID, + authFingerprint: CodexAuthFingerprint.fingerprint(data: liveAuthMaterial.rawData), managedHomePath: persistedManagedAccount.managedHomePath, createdAt: persistedManagedAccount.createdAt, updatedAt: now, diff --git a/Sources/CodexBar/CodexAccountPromotionPlanning.swift b/Sources/CodexBar/CodexAccountPromotionPlanning.swift index ed5427485..8fd94ea83 100644 --- a/Sources/CodexBar/CodexAccountPromotionPlanning.swift +++ b/Sources/CodexBar/CodexAccountPromotionPlanning.swift @@ -58,7 +58,11 @@ struct CodexDisplacedLivePreservationPlanner { } if let targetAuthIdentity = context.target.authIdentity, - CodexIdentityMatcher.matches(targetAuthIdentity.identity, liveAuthIdentity.identity) + CodexIdentityMatcher.matches( + targetAuthIdentity.identity, + lhsEmail: targetAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) { return .none(reason: .targetMatchesLiveAuthIdentity) } @@ -96,7 +100,11 @@ struct CodexDisplacedLivePreservationPlanner { { candidates.first { candidate in guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } @@ -108,8 +116,12 @@ struct CodexDisplacedLivePreservationPlanner { switch liveAuthIdentity.identity { case let .providerAccount(id): let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) - if let destination = candidates.first(where: { $0.persisted.providerAccountID == providerAccountID }), - let reason = self.providerRepairReason(for: destination) + if let destination = candidates.first(where: { + guard $0.persisted.providerAccountID == providerAccountID else { return false } + guard let liveEmail = liveAuthIdentity.email else { return true } + return $0.persisted.email == liveEmail + }), + let reason = self.providerRepairReason(for: destination) { return (destination, reason) } @@ -149,9 +161,16 @@ struct CodexDisplacedLivePreservationPlanner { let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) return candidates.contains { candidate in guard candidate.persisted.providerAccountID == providerAccountID else { return false } + if let liveEmail = liveAuthIdentity.email, candidate.persisted.email != liveEmail { + return false + } guard case .readable = candidate.homeState else { return false } guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return !CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return !CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index c8a4abb73..c8bf58969 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -259,7 +259,12 @@ final class CodexAccountPromotionService { private func convergedActiveSource(for context: PreparedPromotionContext) -> CodexActiveSource? { if let liveAuthIdentity = context.live.authIdentity { let targetIdentity = context.target.authIdentity ?? context.target.persistedIdentity - guard CodexIdentityMatcher.matches(targetIdentity.identity, liveAuthIdentity.identity) else { + guard CodexIdentityMatcher.matches( + targetIdentity.identity, + lhsEmail: targetIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) + else { return nil } @@ -280,7 +285,9 @@ final class CodexAccountPromotionService { guard CodexIdentityMatcher.matches( context.snapshot.runtimeIdentity(for: context.target.persisted), - context.snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: context.snapshot.runtimeEmail(for: context.target.persisted), + context.snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) else { return nil } diff --git a/Sources/CodexBar/CodexAccountReconciliation.swift b/Sources/CodexBar/CodexAccountReconciliation.swift index 782ec12ac..dcd046a0f 100644 --- a/Sources/CodexBar/CodexAccountReconciliation.swift +++ b/Sources/CodexBar/CodexAccountReconciliation.swift @@ -6,6 +6,7 @@ struct CodexVisibleAccount: Equatable, Identifiable { let email: String let workspaceLabel: String? let workspaceAccountID: String? + let authFingerprint: String? let storedAccountID: UUID? let selectionSource: CodexActiveSource let isActive: Bool @@ -18,6 +19,7 @@ struct CodexVisibleAccount: Equatable, Identifiable { email: String, workspaceLabel: String? = nil, workspaceAccountID: String? = nil, + authFingerprint: String? = nil, storedAccountID: UUID?, selectionSource: CodexActiveSource, isActive: Bool, @@ -29,6 +31,7 @@ struct CodexVisibleAccount: Equatable, Identifiable { self.email = email self.workspaceLabel = Self.normalizeWorkspaceLabel(workspaceLabel) self.workspaceAccountID = workspaceAccountID + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) self.storedAccountID = storedAccountID self.selectionSource = selectionSource self.isActive = isActive @@ -54,6 +57,11 @@ struct CodexVisibleAccount: Equatable, Identifiable { return workspaceLabel } + var authenticationHealthLabel: String? { + guard !self.isLive, self.storedAccountID != nil, self.authFingerprint == nil else { return nil } + return "Missing auth" + } + private static func normalizeWorkspaceLabel(_ workspaceLabel: String?) -> String? { guard let trimmed = workspaceLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil @@ -90,6 +98,7 @@ extension CodexVisibleAccountProjection { email: normalizedEmail, workspaceLabel: Self.normalizeWorkspaceLabel(storedAccount.workspaceLabel), workspaceAccountID: storedAccount.workspaceAccountID, + authFingerprint: storedAccount.authFingerprint, storedAccountID: storedAccount.id, selectionSource: .managedAccount(id: storedAccount.id), isLive: false, @@ -101,8 +110,28 @@ extension CodexVisibleAccountProjection { if let liveSystemAccount = snapshot.liveSystemAccount { let normalizedEmail = Self.normalizeVisibleEmail(liveSystemAccount.email) let liveIdentity = snapshot.runtimeIdentity(for: liveSystemAccount) - if let existingIndex = drafts.firstIndex(where: { draft in - CodexIdentityMatcher.matches(draft.identity, liveIdentity) + if let exactStoredAccountID = snapshot.matchingStoredAccountForLiveSystemAccount?.id, + let exactIndex = drafts.firstIndex(where: { $0.storedAccountID == exactStoredAccountID }) + { + let existingDraft = drafts[exactIndex] + let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel) + drafts[exactIndex] = VisibleAccountDraft( + email: existingDraft.email, + workspaceLabel: liveWorkspaceLabel ?? existingDraft.workspaceLabel, + workspaceAccountID: liveSystemAccount.workspaceAccountID ?? existingDraft.workspaceAccountID, + authFingerprint: liveSystemAccount.authFingerprint ?? existingDraft.authFingerprint, + storedAccountID: existingDraft.storedAccountID, + selectionSource: .liveSystem, + isLive: true, + canReauthenticate: existingDraft.canReauthenticate, + canRemove: existingDraft.canRemove, + identity: liveIdentity) + } else if let existingIndex = drafts.firstIndex(where: { draft in + CodexIdentityMatcher.matches( + draft.identity, + lhsEmail: draft.email, + liveIdentity, + rhsEmail: normalizedEmail) }) { let existingDraft = drafts[existingIndex] let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel) @@ -110,6 +139,7 @@ extension CodexVisibleAccountProjection { email: existingDraft.email, workspaceLabel: liveWorkspaceLabel ?? existingDraft.workspaceLabel, workspaceAccountID: liveSystemAccount.workspaceAccountID ?? existingDraft.workspaceAccountID, + authFingerprint: liveSystemAccount.authFingerprint ?? existingDraft.authFingerprint, storedAccountID: existingDraft.storedAccountID, selectionSource: .liveSystem, isLive: true, @@ -121,6 +151,7 @@ extension CodexVisibleAccountProjection { email: normalizedEmail, workspaceLabel: Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel), workspaceAccountID: liveSystemAccount.workspaceAccountID, + authFingerprint: liveSystemAccount.authFingerprint, storedAccountID: nil, selectionSource: .liveSystem, isLive: true, @@ -145,6 +176,7 @@ extension CodexVisibleAccountProjection { email: draft.email, workspaceLabel: draft.workspaceLabel, workspaceAccountID: draft.workspaceAccountID, + authFingerprint: draft.authFingerprint, storedAccountID: draft.storedAccountID, selectionSource: draft.selectionSource, isActive: isActive, @@ -198,6 +230,7 @@ private struct VisibleAccountDraft { let email: String let workspaceLabel: String? let workspaceAccountID: String? + let authFingerprint: String? let storedAccountID: UUID? let selectionSource: CodexActiveSource let isLive: Bool diff --git a/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift new file mode 100644 index 000000000..207ca5b8a --- /dev/null +++ b/Sources/CodexBar/CodexAccountUsageSnapshotStore.swift @@ -0,0 +1,87 @@ +import CodexBarCore +import Foundation + +protocol CodexAccountUsageSnapshotStoring: Sendable { + func load(for accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] + func store(_ snapshots: [CodexAccountUsageSnapshot]) +} + +struct FileCodexAccountUsageSnapshotStore: CodexAccountUsageSnapshotStoring, @unchecked Sendable { + private struct Payload: Codable { + let version: Int + let records: [Record] + } + + private struct Record: Codable { + let id: String + let snapshot: UsageSnapshot? + let error: String? + let sourceLabel: String? + } + + private static let currentVersion = 1 + + private let fileURL: URL + private let fileManager: FileManager + + init(fileURL: URL = Self.defaultURL(), fileManager: FileManager = .default) { + self.fileURL = fileURL + self.fileManager = fileManager + } + + func load(for accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { + guard self.fileManager.fileExists(atPath: self.fileURL.path), + let data = try? Data(contentsOf: self.fileURL), + let payload = try? JSONDecoder().decode(Payload.self, from: data), + payload.version == Self.currentVersion + else { + return [] + } + + let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) + return payload.records.compactMap { record in + guard let account = accountsByID[record.id] else { return nil } + return CodexAccountUsageSnapshot( + account: account, + snapshot: record.snapshot, + error: record.error, + sourceLabel: record.sourceLabel) + } + } + + func store(_ snapshots: [CodexAccountUsageSnapshot]) { + let payload = Payload( + version: Self.currentVersion, + records: snapshots.map { snapshot in + Record( + id: snapshot.id, + snapshot: snapshot.snapshot, + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + }) + let directory = self.fileURL.deletingLastPathComponent() + do { + if !self.fileManager.fileExists(atPath: directory.path) { + try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(payload).write(to: self.fileURL, options: [.atomic]) + #if os(macOS) + try self.fileManager.setAttributes([ + .posixPermissions: NSNumber(value: Int16(0o600)), + ], ofItemAtPath: self.fileURL.path) + #endif + } catch { + // Snapshot hydration is best-effort; never make menu refresh fail because disk cache failed. + } + } + + static func defaultURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + return base + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("codex-account-snapshots.json", isDirectory: false) + } +} diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index ccaad6969..5048744b5 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -43,6 +43,7 @@ struct CodexBarApp: App { let preferencesSelection = PreferencesSelection() let settings = SettingsStore() + Self.applyLanguagePreference(from: settings) let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() @@ -89,7 +90,10 @@ struct CodexBarApp: App { updater: self.appDelegate.updaterController, selection: self.preferencesSelection, managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator) + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + runProviderLoginFlow: { provider in + await self.appDelegate.runProviderLoginFlow(provider) + }) } .defaultSize(width: PreferencesTab.general.preferredWidth, height: PreferencesTab.general.preferredHeight) .windowResizability(.contentSize) @@ -100,6 +104,15 @@ struct CodexBarApp: App { NSApp.activate(ignoringOtherApps: true) _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) } + + private static func applyLanguagePreference(from settings: SettingsStore) { + let language = settings.appLanguage + if language.isEmpty { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } else { + UserDefaults.standard.set([language], forKey: "AppleLanguages") + } + } } // MARK: - Updater abstraction @@ -112,6 +125,7 @@ protocol UpdaterProviding: AnyObject { var unavailableReason: String? { get } var updateStatus: UpdateStatus { get } func checkForUpdates(_ sender: Any?) + func installUpdate() } /// No-op updater used for debug builds and non-bundled runs to suppress Sparkle dialogs. @@ -127,6 +141,7 @@ final class DisabledUpdaterController: UpdaterProviding { } func checkForUpdates(_ sender: Any?) {} + func installUpdate() {} } @MainActor @@ -145,12 +160,25 @@ import Sparkle @MainActor final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate { + private final class ImmediateInstallHandler: @unchecked Sendable { + private let handler: () -> Void + + init(_ handler: @escaping () -> Void) { + self.handler = handler + } + + func install() { + self.handler() + } + } + private lazy var controller = SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) let updateStatus = UpdateStatus() let unavailableReason: String? = nil + private var immediateInstallHandler: ImmediateInstallHandler? init(savedAutoUpdate: Bool) { super.init() @@ -178,20 +206,59 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele self.controller.checkForUpdates(sender) } - nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - Task { @MainActor in - self.updateStatus.isUpdateReady = true + func installUpdate() { + guard let immediateInstallHandler else { + self.controller.checkForUpdates(nil) + return } + + immediateInstallHandler.install() + } + + nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + _ = updater + _ = item } nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + _ = updater + _ = item + _ = error Task { @MainActor in + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { + _ = updater Task { @MainActor in + self.immediateInstallHandler = nil + self.updateStatus.isUpdateReady = false + } + } + + nonisolated func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) + -> Bool + { + _ = updater + _ = item + let installHandler = ImmediateInstallHandler(immediateInstallHandler) + Task { @MainActor in + self.immediateInstallHandler = installHandler + self.updateStatus.isUpdateReady = true + } + return true + } + + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + _ = updater + _ = error + Task { @MainActor in + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } @@ -206,10 +273,12 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele Task { @MainActor in switch choice { case .install, .skip: + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false case .dismiss: self.updateStatus.isUpdateReady = downloaded @unknown default: + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } @@ -325,6 +394,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { TTYCommandRunner.terminateActiveProcessesForAppShutdown() } + func runProviderLoginFlow(_ provider: UsageProvider) async { + self.ensureStatusController() + guard let statusController else { return } + await statusController.runLoginFlowFromSettings(provider: provider) + } + @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) { guard let event = notification.object as? WeeklyLimitResetEvent else { return } guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return } diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index a34629766..36f303a3a 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -20,6 +20,8 @@ struct CodexBarConfigMigrator { let tokenAccountStore: any ProviderTokenAccountStoring } + private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted" + private struct MigrationState { var didUpdate = false var sawLegacySecrets = false @@ -36,13 +38,21 @@ struct CodexBarConfigMigrator { var config = (existing ?? CodexBarConfig.makeDefault()).normalized() var state = MigrationState() - if existing == nil { - self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state) - } - + // applyLegacyCookieSources reads only UserDefaults — cheap, runs unconditionally so + // newly-added cookie-source keys are picked up on every launch. self.applyLegacyCookieSources(userDefaults: userDefaults, config: &config, state: &state) - self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state) - self.migrateLegacyAccounts(stores: stores, config: &config, state: &state) + + let migrationCompleted = userDefaults.bool(forKey: Self.legacyMigrationCompletedKey) + if !migrationCompleted { + // Run once: migrate Keychain/file secrets then clear them. Using a completion flag rather + // than `existing == nil` ensures a crash between config-save and clearLegacyStores can + // finish cleanup on the next launch without re-doing the (already-saved) data migration. + if existing == nil { + self.applyLegacyOrderAndToggles(userDefaults: userDefaults, config: &config, state: &state) + } + self.migrateLegacySecrets(userDefaults: userDefaults, stores: stores, config: &config, state: &state) + self.migrateLegacyAccounts(stores: stores, config: &config, state: &state) + } if state.didUpdate { do { @@ -53,7 +63,12 @@ struct CodexBarConfigMigrator { } if state.sawLegacySecrets || state.sawLegacyAccounts { - self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log) + let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log) + if cleared { + userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey) + } + } else if !migrationCompleted { + userDefaults.set(true, forKey: Self.legacyMigrationCompletedKey) } return config.normalized() @@ -274,11 +289,13 @@ struct CodexBarConfigMigrator { return false } + @discardableResult private static func clearLegacyStores( stores: LegacyStores, sawAccounts: Bool, - log: CodexBarLogger) + log: CodexBarLogger) -> Bool { + var success = true do { try stores.zaiTokenStore.storeToken(nil) try stores.syntheticTokenStore.storeToken(nil) @@ -296,6 +313,7 @@ struct CodexBarConfigMigrator { try stores.ampCookieStore.storeCookieHeader(nil) } catch { log.error("Failed to clear legacy secrets: \(error)") + success = false } if sawAccounts { @@ -304,6 +322,8 @@ struct CodexBarConfigMigrator { try? FileManager.default.removeItem(at: legacyURL) } } + + return success } private static func applyProviderOrder(_ raw: [String], config: CodexBarConfig) -> CodexBarConfig { diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fec1135dc..c7f81ad61 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View { } } + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let accentColor: Color + } + + private struct DetailContent { + let primary: String + let rows: [DetailRow] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -40,6 +52,7 @@ struct CostHistoryChartMenuView: View { Text("No cost history data.") .font(.footnote) .foregroundStyle(.secondary) + .accessibilityLabel("No cost history data available.") } else { Chart { ForEach(model.points) { point in @@ -69,6 +82,8 @@ 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") .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { @@ -88,28 +103,56 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = self.detailContent(model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 @@ -150,6 +198,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 + var maxRenderedBreakdownRows = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -158,6 +207,7 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -181,7 +231,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxRenderedBreakdownRows: maxRenderedBreakdownRows) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +262,18 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + return min(breakdown.count, self.maxVisibleDetailLines) + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,41 +349,56 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { + private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let primary = if let tokens = point.totalTokens { + "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + "\(dayLabel): \(cost)" } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - guard let entry = model.entriesByDateKey[key] else { return nil } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } - let parts = breakdown - .compactMap { item -> String? in - let name = UsageFormatter.modelDisplayName(item.modelName) - guard let detail = UsageFormatter.modelCostDetail( - item.modelName, - costUSD: item.costUSD, - totalTokens: item.totalTokens) - else { return nil } - return "\(name) \(detail)" + private func breakdownRows(key: String, model: Model) -> [DetailRow] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + return breakdown + .sorted { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + + return lhs.modelName > rhs.modelName } - .prefix(3) - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + .prefix(Self.maxVisibleDetailLines) + .enumerated() + .map { index, item in + DetailRow( + id: "\(item.modelName)-\(index)", + title: UsageFormatter.modelDisplayName(item.modelName), + subtitle: UsageFormatter.modelCostDetail( + item.modelName, + costUSD: item.costUSD, + totalTokens: item.totalTokens), + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + 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 9c5ca0b50..a746251bb 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -32,6 +32,7 @@ struct CreditsHistoryChartMenuView: View { Text("No credits history data.") .font(.footnote) .foregroundStyle(.secondary) + .accessibilityLabel("No credits history data available.") } else { Chart { ForEach(model.points) { point in @@ -61,6 +62,8 @@ 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") .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index f2b48f215..d7e667002 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -1,12 +1,10 @@ import AppKit import CodexBarCore import Foundation -import WebKit -/// Handles Cursor login flow using a WebKit-based browser window. -/// Captures session cookies after successful authentication. +/// Opens Cursor in the user's browser and waits until the normal browser-cookie importer can read a session. @MainActor -final class CursorLoginRunner: NSObject { +final class CursorLoginRunner { enum Phase { case loading case waitingLogin @@ -25,198 +23,90 @@ final class CursorLoginRunner: NSObject { let email: String? } - private let browserDetection: BrowserDetection - private var webView: WKWebView? - private var window: NSWindow? - private var continuation: CheckedContinuation? - private var phaseCallback: ((Phase) -> Void)? - private var hasCompletedLogin = false - private let logger = CodexBarLog.logger(LogCategories.cursorLogin) + typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot + typealias Sleeper = @Sendable (UInt64) async throws -> Void + typealias SessionCacheResetter = @Sendable () async -> Void - private static let dashboardURL = URL(string: "https://cursor.com/dashboard")! - private static let loginURLPattern = "authenticator.cursor.sh" + private let loadSnapshot: SnapshotLoader + private let openURL: @MainActor (URL) -> Bool + private let sleeper: Sleeper + private let resetSessionCache: SessionCacheResetter + private let timeout: TimeInterval + private let pollInterval: TimeInterval + private let logger = CodexBarLog.logger(LogCategories.cursorLogin) - init(browserDetection: BrowserDetection) { - self.browserDetection = browserDetection - super.init() + static let authURL = URL(string: "https://authenticator.cursor.sh/")! + + init( + browserDetection: BrowserDetection, + timeout: TimeInterval = 120, + pollInterval: TimeInterval = 2, + openURL: @escaping @MainActor (URL) -> Bool = { NSWorkspace.shared.open($0) }, + loadSnapshot: SnapshotLoader? = nil, + sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) }, + resetSessionCache: @escaping SessionCacheResetter = { + CookieHeaderCache.clear(provider: .cursor) + CursorSessionStore.shared.clearCookies() + }) + { + self.timeout = timeout + self.pollInterval = pollInterval + self.openURL = openURL + self.sleeper = sleeper + self.resetSessionCache = resetSessionCache + self.loadSnapshot = loadSnapshot ?? { + let probe = CursorStatusProbe(browserDetection: browserDetection) + return try await probe.fetch(allowCachedSessions: false) + } } - /// Runs the Cursor login flow in a browser window. - /// Returns the result after the user completes login or cancels. - func run(onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { - // Keep this instance alive during the flow. - WebKitTeardown.retain(self) - self.phaseCallback = onPhaseChange + func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result { onPhaseChange(.loading) self.logger.info("Cursor login started") + await self.resetSessionCache() - return await withCheckedContinuation { continuation in - self.continuation = continuation - self.setupWindow() + guard self.openURL(Self.authURL) else { + let message = "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) } - } - - private func setupWindow() { - // Use a non-persistent store for the login flow; cookies are persisted explicitly. - let config = WKWebViewConfiguration() - config.websiteDataStore = .nonPersistent() - - let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 480, height: 640), configuration: config) - webView.navigationDelegate = self - self.webView = webView - // Create window - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false) - window.isReleasedWhenClosed = false - window.title = "Cursor Login" - window.contentView = webView - window.center() - window.delegate = self - window.makeKeyAndOrderFront(nil) - self.window = window - self.logger.info("Cursor login window opened") - - // Navigate to dashboard (will redirect to login if not authenticated) - let request = URLRequest(url: Self.dashboardURL) - webView.load(request) - } - - private func complete(with result: Result) { - guard let continuation = self.continuation else { return } - self.continuation = nil - self.logger.info("Cursor login completed", metadata: ["outcome": "\(result.outcome)"]) - self.scheduleCleanup() - continuation.resume(returning: result) - } - - private func scheduleCleanup() { - self.logger.info("Cursor login window closing") - WebKitTeardown.scheduleCleanup(owner: self, window: self.window, webView: self.webView) - } + onPhaseChange(.waitingLogin) + let deadline = Date().addingTimeInterval(self.timeout) + var lastError: Error? - private func captureSessionCookies() async { - guard let webView = self.webView else { return } - - let dataStore = webView.configuration.websiteDataStore - let cookies = await dataStore.httpCookieStore.allCookies() - - // Filter for cursor.com cookies - let cursorCookies = cookies.filter { cookie in - cookie.domain.contains("cursor.com") || cookie.domain.contains("cursor.sh") - } - - guard !cursorCookies.isEmpty else { - self.phaseCallback?(.failed("No session cookies found")) - self.logger.warning("Cursor login failed: no session cookies found") - self.complete(with: Result(outcome: .failed("No session cookies found"), email: nil)) - return - } - - // Save cookies to the session store - await CursorSessionStore.shared.setCookies(cursorCookies) - self.logger.info("Cursor session cookies captured", metadata: ["count": "\(cursorCookies.count)"]) - - // Try to get user email - let email = await self.fetchUserEmail() - - self.hasCompletedLogin = true - self.phaseCallback?(.success) - self.complete(with: Result(outcome: .success, email: email)) - } - - private func fetchUserEmail() async -> String? { - do { - let probe = CursorStatusProbe(browserDetection: self.browserDetection) - let snapshot = try await probe.fetch() - return snapshot.accountEmail - } catch { - return nil - } - } -} - -// MARK: - WKNavigationDelegate - -extension CursorLoginRunner: WKNavigationDelegate { - nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor in - guard let url = webView.url else { return } - - let urlString = url.absoluteString - - // Check if on login page - if urlString.contains(Self.loginURLPattern) { - self.phaseCallback?(.waitingLogin) - return - } - - // Check if on dashboard (login successful) - if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { - await self.captureSessionCookies() + repeat { + if Task.isCancelled { + self.logger.info("Cursor login cancelled") + return Result(outcome: .cancelled, email: nil) } - } - } - - nonisolated func webView( - _ webView: WKWebView, - didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) - { - Task { @MainActor in - guard let url = webView.url else { return } - let urlString = url.absoluteString - // Detect redirect to dashboard after login - if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { - // Wait a moment for cookies to be set, then capture - try? await Task.sleep(nanoseconds: 500_000_000) - await self.captureSessionCookies() + do { + let snapshot = try await self.loadSnapshot() + onPhaseChange(.success) + self.logger.info("Cursor login completed", metadata: ["outcome": "success"]) + return Result(outcome: .success, email: snapshot.accountEmail) + } catch { + lastError = error } - } - } - nonisolated func webView( - _ webView: WKWebView, - didFail navigation: WKNavigation!, - withError error: Error) - { - Task { @MainActor in - self.phaseCallback?(.failed(error.localizedDescription)) - self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) - self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) - } - } + guard Date() < deadline else { break } + let delay = UInt64(max(0.1, self.pollInterval) * 1_000_000_000) + try? await self.sleeper(delay) + } while true - nonisolated func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error) - { - Task { @MainActor in - // Ignore cancelled navigations (common during redirects) - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - return - } - self.phaseCallback?(.failed(error.localizedDescription)) - self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) - self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) - } + let message = Self.timeoutMessage(lastError: lastError) + onPhaseChange(.failed(message)) + self.logger.warning("Cursor login timed out", metadata: ["error": message]) + return Result(outcome: .failed(message), email: nil) } -} -// MARK: - NSWindowDelegate - -extension CursorLoginRunner: NSWindowDelegate { - nonisolated func windowWillClose(_ notification: Notification) { - Task { @MainActor in - if !self.hasCompletedLogin { - self.logger.info("Cursor login cancelled") - self.complete(with: Result(outcome: .cancelled, email: nil)) - } + private static func timeoutMessage(lastError: Error?) -> String { + let hint = "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 "Timed out waiting for Cursor login. \(hint) Last error: \(lastError.localizedDescription)" } } diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index 7356f9671..cb3c4f59e 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -4,6 +4,7 @@ enum RelativeTimeFormatters { @MainActor static let full: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .full return formatter }() diff --git a/Sources/CodexBar/HistoricalUsagePace.swift b/Sources/CodexBar/HistoricalUsagePace.swift index b9eeaad8b..dc8a0ac5c 100644 --- a/Sources/CodexBar/HistoricalUsagePace.swift +++ b/Sources/CodexBar/HistoricalUsagePace.swift @@ -80,7 +80,7 @@ actor HistoricalUsageHistoryStore { private static let backfillCalibrationMinimumCredits = 0.001 private static let backfillSampleFractions: [Double] = (0...14).map { Double($0) / 14.0 } private static let coverageTolerance: TimeInterval = 16 * 60 * 60 - private static let resetBucketSeconds: TimeInterval = 60 + private static let resetBucketSeconds: TimeInterval = 5 * 60 private let fileURL: URL private var records: [HistoricalUsageRecord] = [] @@ -762,7 +762,7 @@ enum CodexHistoricalPaceEvaluator { static let minimumWeeksForRisk = 5 private static let recencyTauWeeks: Double = 3 private static let epsilon: Double = 1e-9 - private static let resetBucketSeconds: TimeInterval = 60 + private static let resetBucketSeconds: TimeInterval = 5 * 60 static func evaluate(window: RateWindow, now: Date, dataset: CodexHistoricalDataset?) -> UsagePace? { guard let dataset else { return nil } diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift new file mode 100644 index 000000000..6d2b0e2ca --- /dev/null +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -0,0 +1,91 @@ +import CodexBarCore + +enum IconRemainingResolver { + private static func codexProjection(snapshot: UsageSnapshot) -> CodexConsumerProjection { + CodexConsumerProjection.make( + surface: .menuBar, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: snapshot.updatedAt)) + } + + private static func codexVisibleWindows(snapshot: UsageSnapshot) -> [RateWindow] { + let projection = self.codexProjection(snapshot: snapshot) + return projection.visibleRateLanes.compactMap { projection.rateWindow(for: $0) } + } + + static func resolvedWindows( + snapshot: UsageSnapshot, + style: IconStyle) + -> (primary: RateWindow?, secondary: RateWindow?) + { + if style == .perplexity { + let windows = snapshot.orderedPerplexityDisplayWindows() + return ( + primary: windows.first, + secondary: windows.dropFirst().first) + } + if style == .antigravity { + let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + return ( + primary: windows.first, + secondary: windows.dropFirst().first) + } + if style == .codex { + let windows = self.codexVisibleWindows(snapshot: snapshot) + return ( + primary: windows.first, + secondary: windows.dropFirst().first) + } + return ( + primary: snapshot.primary, + secondary: snapshot.secondary) + } + + static func resolvedRemaining( + snapshot: UsageSnapshot, + style: IconStyle) + -> (primary: Double?, secondary: Double?) + { + if style == .perplexity { + let windows = snapshot.orderedPerplexityDisplayWindows() + return ( + primary: windows.first?.remainingPercent, + secondary: windows.dropFirst().first?.remainingPercent) + } + if style == .antigravity { + let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + return ( + primary: windows.first?.remainingPercent, + secondary: windows.dropFirst().first?.remainingPercent) + } + if style == .codex { + let windows = self.codexVisibleWindows(snapshot: snapshot) + return ( + primary: windows.first?.remainingPercent, + secondary: windows.dropFirst().first?.remainingPercent) + } + return ( + primary: snapshot.primary?.remainingPercent, + secondary: snapshot.secondary?.remainingPercent) + } + + static func resolvedPercents( + snapshot: UsageSnapshot, + style: IconStyle, + showUsed: Bool) + -> (primary: Double?, secondary: Double?) + { + let windows = Self.resolvedWindows(snapshot: snapshot, style: style) + return ( + primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, + secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) + } +} diff --git a/Sources/CodexBar/IconView.swift b/Sources/CodexBar/IconView.swift deleted file mode 100644 index 219021055..000000000 --- a/Sources/CodexBar/IconView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import CodexBarCore -import SwiftUI - -enum IconRemainingResolver { - private static func codexProjection(snapshot: UsageSnapshot) -> CodexConsumerProjection { - CodexConsumerProjection.make( - surface: .menuBar, - context: CodexConsumerProjection.Context( - snapshot: snapshot, - rawUsageError: nil, - liveCredits: nil, - rawCreditsError: nil, - liveDashboard: nil, - rawDashboardError: nil, - dashboardAttachmentAuthorized: false, - dashboardRequiresLogin: false, - now: snapshot.updatedAt)) - } - - private static func codexVisibleWindows(snapshot: UsageSnapshot) -> [RateWindow] { - let projection = self.codexProjection(snapshot: snapshot) - return projection.visibleRateLanes.compactMap { projection.rateWindow(for: $0) } - } - - static func resolvedWindows( - snapshot: UsageSnapshot, - style: IconStyle) - -> (primary: RateWindow?, secondary: RateWindow?) - { - if style == .perplexity { - let windows = snapshot.orderedPerplexityDisplayWindows() - return ( - primary: windows.first, - secondary: windows.dropFirst().first) - } - if style == .antigravity { - let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) - return ( - primary: windows.first, - secondary: windows.dropFirst().first) - } - if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) - return ( - primary: windows.first, - secondary: windows.dropFirst().first) - } - return ( - primary: snapshot.primary, - secondary: snapshot.secondary) - } - - static func resolvedRemaining( - snapshot: UsageSnapshot, - style: IconStyle) - -> (primary: Double?, secondary: Double?) - { - if style == .perplexity { - let windows = snapshot.orderedPerplexityDisplayWindows() - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } - if style == .antigravity { - let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } - if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) - return ( - primary: windows.first?.remainingPercent, - secondary: windows.dropFirst().first?.remainingPercent) - } - return ( - primary: snapshot.primary?.remainingPercent, - secondary: snapshot.secondary?.remainingPercent) - } - - static func resolvedPercents( - snapshot: UsageSnapshot, - style: IconStyle, - showUsed: Bool) - -> (primary: Double?, secondary: Double?) - { - let windows = Self.resolvedWindows(snapshot: snapshot, style: style) - return ( - primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, - secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) - } -} - -@MainActor -struct IconView: View { - let snapshot: UsageSnapshot? - let creditsRemaining: Double? - let isStale: Bool - let showLoadingAnimation: Bool - let style: IconStyle - @State private var phase: CGFloat = 0 - @State private var displayLink = DisplayLinkDriver() - @State private var pattern: LoadingPattern = .knightRider - @State private var debugCycle = false - @State private var cycleIndex = 0 - @State private var cycleCounter = 0 - private let loadingFPS: Double = 12 - // Advance to next pattern every N ticks when debug cycling. - private let cycleIntervalTicks = 20 - private let patterns = LoadingPattern.allCases - - private var isLoading: Bool { - self.showLoadingAnimation && self.snapshot == nil - } - - var body: some View { - Group { - if let snapshot { - let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: self.style) - Image(nsImage: IconRenderer.makeIcon( - primaryRemaining: remaining.primary, - weeklyRemaining: remaining.secondary, - creditsRemaining: self.creditsRemaining, - stale: self.isStale, - style: self.style)) - .renderingMode(.original) - .interpolation(.none) - .frame(width: 20, height: 18, alignment: .center) - .padding(.horizontal, 2) - } else if self.showLoadingAnimation { - // Loading: animate bars with the current pattern until data arrives. - Image(nsImage: self.loadingImage) - .renderingMode(.original) - .interpolation(.none) - .frame(width: 20, height: 18, alignment: .center) - .padding(.horizontal, 2) - .onChange(of: self.displayLink.tick) { _, _ in - self.phase += 0.09 // half-speed animation - if self.debugCycle { - self.cycleCounter += 1 - if self.cycleCounter >= self.cycleIntervalTicks { - self.cycleCounter = 0 - self.cycleIndex = (self.cycleIndex + 1) % self.patterns.count - self.pattern = self.patterns[self.cycleIndex] - } - } - } - } else { - // No animation when usage/account is unavailable; show empty tracks. - Image(nsImage: IconRenderer.makeIcon( - primaryRemaining: nil, - weeklyRemaining: nil, - creditsRemaining: self.creditsRemaining, - stale: self.isStale, - style: self.style)) - .renderingMode(.original) - .interpolation(.none) - .frame(width: 20, height: 18, alignment: .center) - .padding(.horizontal, 2) - } - } - .onChange(of: self.isLoading, initial: true) { _, isLoading in - if isLoading { - self.displayLink.start(fps: self.loadingFPS) - if !self.debugCycle { - self.pattern = self.patterns.randomElement() ?? .knightRider - } - } else { - self.displayLink.stop() - self.debugCycle = false - self.phase = 0 - } - } - .onDisappear { self.displayLink.stop() } - .onReceive(NotificationCenter.default.publisher(for: .codexbarDebugReplayAllAnimations)) { notification in - if let raw = notification.userInfo?["pattern"] as? String, - let selected = LoadingPattern(rawValue: raw) - { - self.debugCycle = false - self.pattern = selected - self.cycleIndex = self.patterns.firstIndex(of: selected) ?? 0 - } else { - self.debugCycle = true - self.cycleIndex = 0 - self.pattern = self.patterns.first ?? .knightRider - } - self.cycleCounter = 0 - self.phase = 0 - } - } - - private var loadingPrimary: Double { - self.pattern.value(phase: Double(self.phase)) - } - - private var loadingSecondary: Double { - self.pattern.value(phase: Double(self.phase + self.pattern.secondaryOffset)) - } - - private var loadingImage: NSImage { - if self.pattern == .unbraid { - let progress = self.loadingPrimary / 100 - return IconRenderer.makeMorphIcon(progress: progress, style: self.style) - } else { - return IconRenderer.makeIcon( - primaryRemaining: self.loadingPrimary, - weeklyRemaining: self.loadingSecondary, - creditsRemaining: nil, - stale: false, - style: self.style) - } - } -} diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift new file mode 100644 index 000000000..70c7e8cf4 --- /dev/null +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -0,0 +1,583 @@ +import CodexBarCore +import SwiftUI + +struct InlineUsageDashboardModel: Equatable { + struct KPI: Equatable { + let title: String + let value: String + let emphasis: Bool + } + + struct Point: Equatable, Identifiable { + let id: String + let label: String + let value: Double + let accessibilityValue: String + } + + enum ValueStyle: Equatable { + case currencyUSD + case currency(symbol: String) + case tokens + } + + let accessibilityLabel: String + let valueStyle: ValueStyle + let kpis: [KPI] + let points: [Point] + let detailLines: [String] +} + +extension UsageMenuCardView.Model { + static func apiProviderUsageNotes(input: Input) -> [String]? { + if input.provider == .openai, + let usage = input.snapshot?.openAIAPIUsage + { + return self.openAIAPIUsageNotes(usage) + } + + if input.provider == .deepgram, + let usage = input.snapshot?.deepgramUsage + { + return usage.displayLines + } + + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let billing = input.snapshot?.minimaxUsage?.billingSummary + { + return [ + "Today: \(UsageFormatter.tokenCountString(billing.todayTokens)) tokens", + "Last 30 days: \(UsageFormatter.tokenCountString(billing.last30DaysTokens)) tokens", + ] + } + + return nil + } + + static func openAIAPIUsageNotes(_ usage: OpenAIAPIUsageSnapshot) -> [String] { + let today = usage.latestDay + let seven = usage.last7Days + let thirty = usage.last30Days + let todayNote = "Today: \(UsageFormatter.usdString(today.costUSD)) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens" + let sevenDayNote = "7d: \(UsageFormatter.usdString(seven.costUSD)) · " + + "\(UsageFormatter.tokenCountString(seven.requests)) requests" + let thirtyDayNote = "30d: \(UsageFormatter.tokenCountString(thirty.totalTokens)) tokens · " + + "\(UsageFormatter.tokenCountString(thirty.requests)) requests" + var notes: [String] = [ + todayNote, + sevenDayNote, + thirtyDayNote, + ] + if let topModel = usage.topModels.first { + notes.append("Top model: \(topModel.name)") + } + return notes + } + + static func inlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { + if let usage = input.snapshot?.openAIAPIUsage { + return self.openAIAPIInlineDashboard(usage) + } + if input.provider == .claude, + let usage = input.snapshot?.claudeAdminAPIUsage + { + return Self.claudeAdminAPIInlineDashboard(usage) + } + if input.provider == .openrouter, + let usage = input.snapshot?.openRouterUsage + { + 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 + { + return Self.zaiInlineDashboard(modelUsage: modelUsage, now: input.now) + } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let billing = input.snapshot?.minimaxUsage?.billingSummary, + !billing.daily.isEmpty + { + return Self.minimaxInlineDashboard(billing) + } + if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), + input.tokenCostUsageEnabled, + let tokenSnapshot = input.tokenSnapshot, + !tokenSnapshot.daily.isEmpty + { + return Self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + } + return nil + } + + fileprivate static func openAIAPIInlineDashboard(_ usage: OpenAIAPIUsageSnapshot) -> InlineUsageDashboardModel { + let today = usage.latestDay + let last7 = usage.last7Days + let last30 = usage.last30Days + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.costUSD, + accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + } + var details = [ + "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens · " + + "\(UsageFormatter.tokenCountString(last30.requests)) requests", + ] + if let topModel = usage.topModels.first { + details.append("Top model: \(Self.shortModelName(topModel.name))") + } + return InlineUsageDashboardModel( + accessibilityLabel: "OpenAI 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: "30d spend", value: UsageFormatter.usdString(last30.costUSD), emphasis: false), + .init(title: "Today req", value: UsageFormatter.tokenCountString(today.requests), emphasis: false), + ], + points: points, + detailLines: details) + } + + fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) + -> InlineUsageDashboardModel + { + let today = usage.latestDay + let last7 = usage.last7Days + let last30 = usage.last30Days + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.costUSD, + accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + } + var details = [ + "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", + "Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens", + ] + if let topModel = usage.topModels.first { + details.append("Top model: \(Self.shortModelName(topModel.name))") + } + return InlineUsageDashboardModel( + accessibilityLabel: "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: "30d spend", + value: UsageFormatter.usdString(last30.costUSD), + emphasis: false), + .init( + title: "Today tokens", + value: UsageFormatter.tokenCountString(today.totalTokens), + emphasis: false), + ], + points: points, + detailLines: details) + } + + private static func costHistoryInlineDashboard( + provider: UsageProvider, + snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel + { + let points = snapshot.daily.suffix(30).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))") + } + 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))") + } + if provider == .bedrock { + details.append("AWS Cost Explorer billing can lag.") + } else { + details.append(UsageFormatter.costEstimateHint(provider: provider)) + } + let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue + return InlineUsageDashboardModel( + accessibilityLabel: "\(providerName) 30 day cost trend", + valueStyle: .currencyUSD, + kpis: [ + .init( + title: provider == .bedrock ? "Latest" : "Today", + value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", + emphasis: true), + .init( + title: "30d cost", + value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", + emphasis: false), + .init( + title: "30d tokens", + value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false), + .init( + title: "Latest tokens", + value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false), + ], + points: points, + detailLines: details) + } + + private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { + let periodValues: [(String, String, Double?)] = [ + ("day", "Today", usage.keyUsageDaily), + ("week", "Week", usage.keyUsageWeekly), + ("month", "Month", usage.keyUsageMonthly), + ] + let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in + guard let value else { return nil } + return InlineUsageDashboardModel.Point( + id: id, + label: label, + value: value, + accessibilityValue: "\(label): \(Self.openRouterCurrencyString(value))") + } + guard !points.isEmpty else { return nil } + var details: [String] = [] + if let rate = usage.rateLimit { + details.append("Rate limit: \(rate.requests) / \(rate.interval)") + } + switch usage.keyQuotaStatus { + case .available: + if let remaining = usage.keyRemaining { + details.append("Key remaining: \(Self.openRouterCurrencyString(remaining))") + } + case .noLimitConfigured: + details.append("No limit set for the API key") + case .unavailable: + details.append("API key limit unavailable right now") + } + return InlineUsageDashboardModel( + accessibilityLabel: "OpenRouter API key spend trend", + valueStyle: .currencyUSD, + kpis: [ + .init(title: "Balance", value: Self.openRouterCurrencyString(usage.balance), emphasis: true), + .init( + title: "Today", + value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—", + emphasis: false), + .init( + title: "Week", + value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—", + emphasis: false), + .init( + title: "Month", + value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—", + emphasis: false), + ], + points: points, + 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 } + let total = bars.reduce(0) { $0 + $1.totalTokens } + let latest = bars.last + let peak = bars.max { $0.totalTokens < $1.totalTokens } + let points = bars.enumerated().map { index, bar in + InlineUsageDashboardModel.Point( + id: "\(index)-\(bar.label)", + label: bar.label, + value: Double(bar.totalTokens), + accessibilityValue: "\(bar.label): \(UsageFormatter.tokenCountString(bar.totalTokens)) tokens") + } + let topModel = Self.topZaiModel(from: bars) + return InlineUsageDashboardModel( + accessibilityLabel: "z.ai hourly token trend", + valueStyle: .tokens, + kpis: [ + .init(title: "24h tokens", value: UsageFormatter.tokenCountString(total), emphasis: true), + .init( + title: "Latest hour", + value: latest.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", + emphasis: false), + .init( + title: "Peak hour", + value: peak.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", + emphasis: false), + .init(title: "Models", value: "\(modelUsage.modelNames.count)", emphasis: false), + ], + points: points, + detailLines: topModel.map { ["Top model: \(Self.shortModelName($0))"] } ?? []) + } + + private static func minimaxInlineDashboard(_ billing: MiniMaxBillingSummary) -> InlineUsageDashboardModel { + let points = billing.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: Double($0.tokens), + accessibilityValue: "\($0.day): \(UsageFormatter.tokenCountString($0.tokens)) tokens") + } + var details = ["30d billing history from MiniMax web session"] + if let topModel = billing.topModels.first { + details.append("Top model: \(Self.shortModelName(topModel.name))") + } + if let topMethod = billing.topMethods.first { + details.append("Top method: \(Self.shortModelName(topMethod.name))") + } + if let cash = billing.last30DaysCash { + details.append("30d cash: \(Self.minimaxCashString(cash))") + } + return InlineUsageDashboardModel( + accessibilityLabel: "MiniMax 30 day token usage trend", + valueStyle: .tokens, + kpis: [ + .init( + title: "Today", + value: UsageFormatter.tokenCountString(billing.todayTokens), + emphasis: true), + .init( + title: "30d tokens", + value: UsageFormatter.tokenCountString(billing.last30DaysTokens), + emphasis: false), + .init( + title: "Today cash", + value: billing.todayCash.map(Self.minimaxCashString) ?? "—", + emphasis: false), + .init( + title: "Models", + value: "\(billing.topModels.count)", + emphasis: false), + ], + points: points, + detailLines: details) + } + + private static func topCostModel(from entries: [CostUsageDailyReport.Entry]) -> String? { + var scores: [String: (cost: Double, tokens: Int)] = [:] + for entry in entries { + for model in entry.modelBreakdowns ?? [] { + var score = scores[model.modelName] ?? (0, 0) + score.cost += model.costUSD ?? 0 + score.tokens += model.totalTokens ?? 0 + scores[model.modelName] = score + } + } + return scores.max { + if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens } + return $0.value.cost < $1.value.cost + }?.key + } + + private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { + var tokens: [String: Int] = [:] + for entry in entries { + for model in entry.models { + tokens[model.name, default: 0] += model.totalTokens + } + } + return tokens.max { + if $0.value == $1.value { return $0.key > $1.key } + return $0.value < $1.value + }?.key + } + + private static func topZaiModel(from bars: [ZaiHourlyBar]) -> String? { + var tokens: [String: Int] = [:] + for bar in bars { + for segment in bar.segments { + tokens[segment.model, default: 0] += segment.tokens + } + } + return tokens.max { + if $0.value == $1.value { return $0.key > $1.key } + return $0.value < $1.value + }?.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) + } + + private static func minimaxCashString(_ value: Double) -> String { + String(format: "%.2f", max(0, value)) + } + + private static func shortDayLabel(_ day: String) -> String { + let pieces = day.split(separator: "-") + guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } + return "\(rawDay)" + } + + private static func shortModelName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 26 else { return trimmed } + return String(trimmed.prefix(25)) + "…" + } +} + +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 + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + self.kpis + MiniUsageBars(model: self.model) + .frame(height: 58) + .accessibilityLabel(self.model.accessibilityLabel) + self.detailLines + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var kpis: some View { + LazyVGrid( + columns: [ + GridItem(.flexible(minimum: 118), alignment: .leading), + GridItem(.flexible(minimum: 100), alignment: .leading), + ], + alignment: .leading, + spacing: 6) + { + ForEach(Array(self.model.kpis.enumerated()), id: \.offset) { _, kpi in + KPIBlock(title: kpi.title, value: kpi.value, emphasis: kpi.emphasis) + } + } + } + + private var detailLines: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(self.model.detailLines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(.caption) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + } + + private struct KPIBlock: View { + let title: String + let value: String + let emphasis: Bool + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(self.title) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + Text(self.value) + .font(self.emphasis ? .headline : .subheadline) + .fontWeight(.semibold) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private struct MiniUsageBars: View { + let model: InlineUsageDashboardModel + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + let maxValue = max(self.model.points.map(\.value).max() ?? 0, 1) + HStack(alignment: .bottom, spacing: 2) { + ForEach(self.model.points) { point in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(self.fill(for: point, maxValue: maxValue)) + .frame(maxWidth: .infinity) + .frame(height: self.height(for: point, maxValue: maxValue)) + .accessibilityLabel(point.accessibilityValue) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .overlay(alignment: .bottomLeading) { + Rectangle() + .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.22)) + .frame(height: 1) + } + } + + private func height(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> CGFloat { + let ratio = point.value / maxValue + guard ratio > 0 else { return 1 } + return CGFloat(max(3, min(58, ratio * 58))) + } + + private func fill(for point: InlineUsageDashboardModel.Point, maxValue: Double) -> Color { + let ratio = max(0.18, min(1, point.value / maxValue)) + if self.isHighlighted { + return Color.white.opacity(0.55 + ratio * 0.35) + } + switch self.model.valueStyle { + case .currencyUSD, .currency: + return Color(red: 0.81, green: 0.56, blue: 0.24).opacity(0.42 + ratio * 0.58) + case .tokens: + return Color(red: 0.48, green: 0.41, blue: 0.86).opacity(0.42 + ratio * 0.58) + } + } + } +} diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index a6add39ab..abbeb0caa 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -134,7 +134,7 @@ enum KeychainPromptCoordinator { let alert = NSAlert() alert.messageText = title alert.informativeText = message - alert.addButton(withTitle: "OK") + alert.addButton(withTitle: L("OK")) _ = alert.runModal() } } diff --git a/Sources/CodexBar/KimiK2TokenStore.swift b/Sources/CodexBar/KimiK2TokenStore.swift index 9ad23c028..ed3cf55aa 100644 --- a/Sources/CodexBar/KimiK2TokenStore.swift +++ b/Sources/CodexBar/KimiK2TokenStore.swift @@ -28,6 +28,10 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { private let account = "kimi-k2-api-token" func loadToken() throws -> String? { + guard !KeychainAccessGate.isDisabled else { + Self.log.debug("Keychain access disabled; skipping token load") + return nil + } var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -66,6 +70,10 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { } func storeToken(_ token: String?) throws { + guard !KeychainAccessGate.isDisabled else { + Self.log.debug("Keychain access disabled; skipping token store") + return + } let cleaned = token?.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned == nil || cleaned?.isEmpty == true { try self.deleteTokenIfPresent() @@ -104,6 +112,7 @@ struct KeychainKimiK2TokenStore: KimiK2TokenStoring { } private func deleteTokenIfPresent() throws { + guard !KeychainAccessGate.isDisabled else { return } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift new file mode 100644 index 000000000..d9a3bbfc6 --- /dev/null +++ b/Sources/CodexBar/Localization.swift @@ -0,0 +1,97 @@ +import Foundation + +private func appLanguageDefaults() -> UserDefaults { + if Bundle.main.bundleIdentifier != nil { + return .standard + } + if UserDefaults.standard.object(forKey: "appLanguage") != nil { + return .standard + } + // Fallback for running outside a .app bundle (swift run / debug builds) + return UserDefaults(suiteName: "CodexBar") ?? .standard +} + +func codexBarLocalizationResourceBundle( + mainBundle: Bundle = .main, + bundleName: String = "CodexBar_CodexBar") -> Bundle +{ + guard mainBundle.bundleURL.pathExtension == "app" else { + return Bundle.module + } + + if let url = mainBundle.url(forResource: bundleName, withExtension: "bundle"), + let bundle = Bundle(url: url) + { + return bundle + } + + if let resourceURL = mainBundle.resourceURL?.absoluteURL, + let bundle = Bundle(url: resourceURL.appendingPathComponent("\(bundleName).bundle")) + { + return bundle + } + + return mainBundle +} + +private func localizedBundle() -> Bundle { + let resourceBundle = codexBarLocalizationResourceBundle() + let language = appLanguageDefaults().string(forKey: "appLanguage") ?? "" + if !language.isEmpty { + if let bundle = lprojBundle(named: language, in: resourceBundle) { + return bundle + } + } else { + // System mode: follow macOS language preferences + if let preferred = resourceBundle.preferredLocalizations.first, + let bundle = lprojBundle(named: preferred, in: resourceBundle) + { + return bundle + } + } + // Fallback to en.lproj + if let path = resourceBundle.path(forResource: "en", ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + return resourceBundle +} + +private func lprojBundle(named language: String, in resourceBundle: Bundle) -> Bundle? { + let candidates = [language, language.lowercased()] + for candidate in candidates where !candidate.isEmpty { + if let path = resourceBundle.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) + { + return bundle + } + } + return nil +} + +func L(_ key: String) -> String { + let resourceBundle = codexBarLocalizationResourceBundle() + return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle) +} + +func L(_ key: String, _ arguments: CVarArg...) -> String { + String(format: L(key), arguments: arguments) +} + +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) + if !trimmed.isEmpty, value != key { + return value + } + + guard bundle.bundleURL.lastPathComponent != "en.lproj", + let englishBundle = lprojBundle(named: "en", in: resourceBundle) + else { + return trimmed.isEmpty ? key : value + } + + let fallback = englishBundle.localizedString(forKey: key, value: nil, table: nil) + return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback +} diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index b28bd58e2..7b6d6ec24 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation @@ -16,11 +17,27 @@ protocol ManagedCodexIdentityReading: Sendable { protocol ManagedCodexWorkspaceResolving: Sendable { func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] +} + +extension ManagedCodexWorkspaceResolving { + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + [] + } +} + +protocol ManagedCodexWorkspaceSelecting: Sendable { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? } enum ManagedCodexAccountServiceError: Error, Equatable { case loginFailed case missingEmail + case workspaceSelectionCancelled case unsafeManagedHome(String) } @@ -102,6 +119,65 @@ struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { workspaceAccountID: normalizedProviderAccountID, workspaceLabel: cachedLabel) } + + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + guard let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials) + else { + return [] + } + + for identity in identities { + try? self.workspaceCache.store(identity) + } + return identities + } +} + +struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + guard workspaces.count > 1 else { return workspaces.first } + + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false) + let sortedWorkspaces = workspaces.sorted { lhs, rhs in + self.workspaceTitle(lhs) < self.workspaceTitle(rhs) + } + for workspace in sortedWorkspaces { + popup.addItem(withTitle: self.workspaceTitle(workspace)) + popup.lastItem?.representedObject = workspace.workspaceAccountID + } + if let currentWorkspaceID, + let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID }) + { + popup.selectItem(at: selectedIndex) + } + + let alert = NSAlert() + alert.messageText = L("Choose Codex workspace") + alert.informativeText = String(format: L("multiple_workspaces_found"), email) + alert.alertStyle = .informational + alert.accessoryView = popup + alert.addButton(withTitle: L("Add Workspace")) + alert.addButton(withTitle: L("Cancel")) + + guard alert.runModal() == .alertFirstButtonReturn else { + return nil + } + let selectedWorkspaceID = popup.selectedItem?.representedObject as? String + return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID } + } + + private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String { + workspace.workspaceLabel ?? workspace.workspaceAccountID + } } @MainActor @@ -111,6 +187,7 @@ final class ManagedCodexAccountService { private let loginRunner: any ManagedCodexLoginRunning private let identityReader: any ManagedCodexIdentityReading private let workspaceResolver: any ManagedCodexWorkspaceResolving + private let workspaceSelector: any ManagedCodexWorkspaceSelecting private let fileManager: FileManager init( @@ -119,6 +196,7 @@ final class ManagedCodexAccountService { loginRunner: any ManagedCodexLoginRunning, identityReader: any ManagedCodexIdentityReading, workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(), fileManager: FileManager = .default) { self.store = store @@ -126,6 +204,7 @@ final class ManagedCodexAccountService { self.loginRunner = loginRunner self.identityReader = identityReader self.workspaceResolver = workspaceResolver + self.workspaceSelector = workspaceSelector self.fileManager = fileManager } @@ -136,6 +215,7 @@ final class ManagedCodexAccountService { loginRunner: DefaultManagedCodexLoginRunner(), identityReader: DefaultManagedCodexIdentityReader(), workspaceResolver: DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: CodexWorkspaceAlertSelector(), fileManager: fileManager) } @@ -160,18 +240,23 @@ final class ManagedCodexAccountService { else { throw ManagedCodexAccountServiceError.missingEmail } - let providerAccountID: String? = switch identity.identity { + let authenticatedProviderAccountID: String? = switch identity.identity { case let .providerAccount(id): ManagedCodexAccount.normalizeProviderAccountID(id) case .emailOnly, .unresolved: nil } - let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let providerAccountID { - await self.workspaceResolver.resolveWorkspaceIdentity( + let selectedWorkspace = try await self.selectedWorkspaceIdentity( + email: rawEmail, + homePath: homeURL.path, + authenticatedProviderAccountID: authenticatedProviderAccountID) + let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID + let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace { + selectedWorkspace + } else { + await self.resolvedWorkspaceIdentity( homePath: homeURL.path, providerAccountID: providerAccountID) - } else { - nil } let now = Date().timeIntervalSince1970 @@ -191,6 +276,9 @@ final class ManagedCodexAccountService { providerAccountID: persistedMetadata.providerAccountID, workspaceLabel: persistedMetadata.workspaceLabel, workspaceAccountID: persistedMetadata.workspaceAccountID, + authFingerprint: CodexAuthFingerprint.fingerprint( + homePath: homeURL.path, + fileManager: self.fileManager), managedHomePath: homeURL.path, createdAt: existing?.createdAt ?? now, updatedAt: now, @@ -225,14 +313,14 @@ final class ManagedCodexAccountService { guard let account = snapshot.account(id: id) else { return } let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true) - try self.homeFactory.validateManagedHomeForDeletion(homeURL) + let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil let remaining = snapshot.accounts.filter { $0.id != id } try self.store.storeAccounts(ManagedCodexAccountSet( version: snapshot.version, accounts: remaining)) - if self.fileManager.fileExists(atPath: homeURL.path) { + if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) { try? self.fileManager.removeItem(at: homeURL) } } @@ -245,6 +333,51 @@ final class ManagedCodexAccountService { } } + private func selectedWorkspaceIdentity( + email: String, + homePath: String, + authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity? + { + let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath) + guard workspaces.count > 1 else { + return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID } + } + guard let selected = await self.workspaceSelector.selectWorkspace( + email: email, + currentWorkspaceID: authenticatedProviderAccountID, + workspaces: workspaces) + else { + throw ManagedCodexAccountServiceError.workspaceSelectionCancelled + } + try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath) + return selected + } + + private func resolvedWorkspaceIdentity( + homePath: String, + providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity? + { + guard let providerAccountID else { return nil } + return await self.workspaceResolver.resolveWorkspaceIdentity( + homePath: homePath, + providerAccountID: providerAccountID) + } + + private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + let credentials = try CodexOAuthCredentialsStore.load(env: env) + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + idToken: credentials.idToken, + accountId: workspaceID, + lastRefresh: credentials.lastRefresh), + env: env) + } + private func reconciledExistingAccount( authenticatedEmail: String, providerAccountID: String?, diff --git a/Sources/CodexBar/MenuBarDisplayMode.swift b/Sources/CodexBar/MenuBarDisplayMode.swift index 8daa30ccf..484d20969 100644 --- a/Sources/CodexBar/MenuBarDisplayMode.swift +++ b/Sources/CodexBar/MenuBarDisplayMode.swift @@ -12,17 +12,17 @@ enum MenuBarDisplayMode: String, CaseIterable, Identifiable { var label: String { switch self { - case .percent: "Percent" - case .pace: "Pace" - case .both: "Both" + case .percent: L("display_mode_percent") + case .pace: L("display_mode_pace") + case .both: L("display_mode_both") } } var description: String { switch self { - case .percent: "Show remaining/used percentage (e.g. 45%)" - case .pace: "Show pace indicator (e.g. +5%)" - case .both: "Show both percentage and pace (e.g. 45% · +5%)" + case .percent: L("display_mode_percent_desc") + case .pace: L("display_mode_pace_desc") + case .both: L("display_mode_both_desc") } } } diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index f013d636e..520878d4d 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -110,6 +110,12 @@ enum MenuBarMetricWindowResolver { secondary: snapshot.secondary, tertiary: snapshot.tertiary) } + if provider == .claude, + Self.shouldUseClaudeSpendLimit(providerCost: snapshot.providerCost, snapshot: snapshot), + let extraUsage = Self.extraUsageWindow(snapshot: snapshot) + { + return extraUsage + } return snapshot.primary ?? snapshot.secondary } @@ -144,6 +150,22 @@ enum MenuBarMetricWindowResolver { return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + private static func shouldUseClaudeSpendLimit( + providerCost: ProviderCostSnapshot?, + snapshot: UsageSnapshot) + -> Bool + { + guard providerCost?.limit ?? 0 > 0, + snapshot.secondary == nil, + snapshot.tertiary == nil + else { return false } + guard let primary = snapshot.primary else { return true } + return primary.usedPercent == 0 + && primary.windowMinutes == 5 * 60 + && primary.resetsAt == nil + && primary.resetDescription == nil + } + private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? { guard let cost = snapshot?.providerCost, cost.limit > 0 else { return nil } let usedPercent = max(0, min(100, (cost.used / cost.limit) * 100)) diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift new file mode 100644 index 000000000..3127cd297 --- /dev/null +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -0,0 +1,271 @@ +import AppKit +import Foundation + +struct StatusItemVisibilitySnapshot: Equatable { + let isVisible: Bool + let hasButton: Bool + let hasWindow: Bool + let hasScreen: Bool + let isOnCurrentScreen: Bool + let buttonWidth: CGFloat + + init( + isVisible: Bool, + hasButton: Bool, + hasWindow: Bool, + hasScreen: Bool, + isOnCurrentScreen: Bool = true, + buttonWidth: CGFloat) + { + self.isVisible = isVisible + self.hasButton = hasButton + self.hasWindow = hasWindow + self.hasScreen = hasScreen + self.isOnCurrentScreen = isOnCurrentScreen + self.buttonWidth = buttonWidth + } +} + +extension StatusItemVisibilitySnapshot: CustomStringConvertible { + var description: String { + "visible=\(self.isVisible),button=\(self.hasButton),window=\(self.hasWindow)," + + "screen=\(self.hasScreen),currentScreen=\(self.isOnCurrentScreen)," + + "width=\(String(format: "%.1f", Double(self.buttonWidth)))" + } +} + +@MainActor +func isStatusItemBlocked(_ item: NSStatusItem) -> Bool { + MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: MenuBarVisibilityWatcher.visibilitySnapshot(item)) +} + +enum MenuBarVisibilityWatcher { + static let guidanceShownKey = "hasShownTahoeAllowListGuidance" + static let guidanceLastShownAtKey = "tahoeAllowListGuidanceLastShownAt" + static let guidanceRepeatInterval: TimeInterval = 24 * 60 * 60 + static let startupFreshnessInterval: TimeInterval = 10 + static let startupCheckDelay: TimeInterval = 2 + static let settingsURL = URL(string: "x-apple.systempreferences:com.apple.MenuBarSettings")! + + @MainActor + static func visibilitySnapshot(_ item: NSStatusItem) -> StatusItemVisibilitySnapshot { + let screen = item.button?.window?.screen + return StatusItemVisibilitySnapshot( + isVisible: item.isVisible, + hasButton: item.button != nil, + hasWindow: item.button?.window != nil, + hasScreen: screen != nil, + isOnCurrentScreen: screen.map(self.isCurrentScreen) ?? false, + buttonWidth: item.button?.frame.size.width ?? 0) + } + + @MainActor + private static func isCurrentScreen(_ screen: NSScreen) -> Bool { + let screenNumber = self.screenNumber(screen) + return NSScreen.screens.contains { candidate in + if let screenNumber, let candidateNumber = self.screenNumber(candidate) { + return candidateNumber == screenNumber + } + return candidate === screen + } + } + + private static func screenNumber(_ screen: NSScreen) -> NSNumber? { + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber + } + + 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 + } + + static func hasBlockedVisibleSnapshots(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + let visibleItems = snapshots.filter(\.isVisible) + guard !visibleItems.isEmpty else { return false } + return visibleItems.allSatisfy { snapshot in + self.isBlockedSnapshot(snapshot: snapshot) + } + } + + static func hasAnyBlockedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + snapshots.contains { snapshot in + snapshot.isVisible && self.isBlockedSnapshot(snapshot: snapshot) + } + } + + @MainActor + static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { + items.map { item in + self.visibilitySnapshot(item) + } + } + + @MainActor + static func hasBlockedVisibleStatusItems(_ items: [NSStatusItem]) -> Bool { + self.hasBlockedVisibleSnapshots(self.visibilitySnapshots(items)) + } + + static func shouldAttemptStartupRecovery( + appLaunchedAt: Date, + now: Date = Date(), + snapshots: [StatusItemVisibilitySnapshot]) + -> Bool + { + guard now.timeIntervalSince(appLaunchedAt) <= self.startupFreshnessInterval else { return false } + return self.hasAnyBlockedVisibleSnapshot(snapshots) + } + + static func shouldAttemptScreenChangeRecovery( + 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 + } + } + + static func shouldShowGuidance(defaults: UserDefaults, now: Date = Date()) -> Bool { + guard defaults.bool(forKey: self.guidanceShownKey) else { return true } + let lastShownAt = defaults.double(forKey: self.guidanceLastShownAtKey) + guard lastShownAt > 0 else { return false } + return now.timeIntervalSince1970 - lastShownAt >= self.guidanceRepeatInterval + } + + static func markGuidanceShown(defaults: UserDefaults, now: Date = Date()) { + defaults.set(true, forKey: self.guidanceShownKey) + defaults.set(now.timeIntervalSince1970, forKey: self.guidanceLastShownAtKey) + } + + @MainActor + static func presentGuidance( + defaults: UserDefaults, + now: Date = Date(), + openURL: (URL) -> Void = { NSWorkspace.shared.open($0) }) + { + self.markGuidanceShown(defaults: defaults, now: now) + + let alert = NSAlert() + alert.messageText = L("CodexBar can't show its menu bar icon") + alert.informativeText = L( + "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.") + alert.alertStyle = .warning + alert.addButton(withTitle: L("Open Menu Bar Settings")) + alert.addButton(withTitle: L("Dismiss")) + + if alert.runModal() == .alertFirstButtonReturn { + openURL(self.settingsURL) + } + } +} + +extension StatusItemController { + func scheduleStartupStatusItemVisibilityCheck(appLaunchedAt: Date = Date()) { + guard !SettingsStore.isRunningTests else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + MenuBarVisibilityWatcher.startupCheckDelay) { [weak self] in + Task { @MainActor [weak self] in + self?.checkStartupStatusItemVisibility(appLaunchedAt: appLaunchedAt) + } + } + } + + private func checkStartupStatusItemVisibility(appLaunchedAt: Date, now: Date = Date()) { + let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: appLaunchedAt, + now: now, + snapshots: snapshots) + else { + return + } + + self.menuLogger.error( + "Status item failed to materialize; recreating status items", + metadata: ["snapshots": snapshots.map(\.description).joined(separator: " | ")]) + self.recreateStatusItemsForVisibilityRecovery() + + let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + guard MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: appLaunchedAt, + now: now, + snapshots: recoveredSnapshots) + else { + self.menuLogger.info( + "Status item materialized after recreation", + metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + return + } + + self.menuLogger.error( + "Status item still failed to materialize after recreation", + metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + guard #available(macOS 26.0, *), + MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) + else { + return + } + MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults, now: now) + } + + @objc func handleScreenParametersDidChange(_: Notification) { + let previousScreenCount = max( + self.pendingScreenChangePreviousCount ?? self.lastKnownScreenCount, + self.lastKnownScreenCount) + let currentScreenCount = NSScreen.screens.count + self.pendingScreenChangePreviousCount = previousScreenCount + self.lastKnownScreenCount = currentScreenCount + self.scheduleScreenChangeStatusItemVisibilityCheck( + previousScreenCount: previousScreenCount, + currentScreenCount: currentScreenCount) + } + + private func scheduleScreenChangeStatusItemVisibilityCheck( + previousScreenCount: Int, + currentScreenCount: Int) + { + guard !SettingsStore.isRunningTests else { return } + self.screenChangeVisibilityTask?.cancel() + self.screenChangeVisibilityTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .milliseconds(750)) + } catch { + return + } + self?.checkScreenChangeStatusItemVisibility( + previousScreenCount: previousScreenCount, + currentScreenCount: currentScreenCount) + } + } + + private func checkScreenChangeStatusItemVisibility(previousScreenCount: Int, currentScreenCount: Int) { + self.pendingScreenChangePreviousCount = nil + let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + guard MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + previousScreenCount: previousScreenCount, + currentScreenCount: currentScreenCount, + snapshots: snapshots) + else { + return + } + + self.menuLogger.error( + "Display configuration changed; recreating status items", + metadata: [ + "previousScreenCount": "\(previousScreenCount)", + "currentScreenCount": "\(currentScreenCount)", + "snapshots": snapshots.map(\.description).joined(separator: " | "), + ]) + self.recreateStatusItemsForVisibilityRecovery() + } + + private var startupVisibilityStatusItems: [NSStatusItem] { + [self.statusItem] + Array(self.statusItems.values) + } +} diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift new file mode 100644 index 000000000..89505ac5d --- /dev/null +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -0,0 +1,21 @@ +import CodexBarCore + +extension CodexConsumerProjection.RateLane { + var quotaWarningWindow: QuotaWarningWindow { + switch self { + case .session: + .session + case .weekly: + .weekly + } + } +} + +extension UsageMenuCardView.Model { + static func warningMarkerPercents(thresholds: [Int]?, showUsed: Bool) -> [Double] { + guard let thresholds, !thresholds.isEmpty else { return [] } + return QuotaWarningThresholds.active(thresholds) + .map { showUsed ? 100 - Double($0) : Double($0) } + .filter { $0 > 0 && $0 < 100 } + } +} diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift new file mode 100644 index 000000000..586f69aa7 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -0,0 +1,190 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func creditsLine( + metadata: ProviderMetadata, + credits: CreditsSnapshot?, + error: String?) -> String? + { + guard metadata.supportsCredits else { return nil } + if let credits { + return UsageFormatter.creditsString(from: credits.remaining) + } + if let error, !error.isEmpty { + return error.trimmingCharacters(in: .whitespacesAndNewlines) + } + return metadata.creditsHint + } + + static func tokenUsageSection( + provider: UsageProvider, + enabled: Bool, + snapshot: CostUsageTokenSnapshot?, + error: String?) -> TokenUsageSection? + { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + return nil + } + guard enabled else { return nil } + guard let snapshot else { return nil } + + let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } + 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 "Today: \(sessionCost)" + }() + + let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) + let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) + let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } + let monthLine: String = { + if let monthTokens { + return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + } + return "Last 30 days: \(monthCost)" + }() + let err = (error?.isEmpty ?? true) ? nil : error + return TokenUsageSection( + sessionLine: sessionLine, + monthLine: monthLine, + hintLine: Self.tokenUsageHint(provider: provider), + errorLine: err, + errorCopyText: (error?.isEmpty ?? true) ? nil : error) + } + + static func tokenUsageHint(provider: UsageProvider) -> String? { + switch provider { + case .codex: + "Estimated from local Codex logs for the selected account." + case .claude: + UsageFormatter.costEstimateHint(provider: provider) + case .vertexai: + UsageFormatter.costEstimateHint + case .bedrock: + "Reported by AWS Cost Explorer; daily billing data can lag." + default: + nil + } + } + + private static func bedrockLatestBillingDayLabel(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))" + } + + private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry]) + -> CostUsageDailyReport.Entry? + { + entries.max { lhs, rhs in + let lDate = Self.bedrockBillingDate(from: lhs.date) ?? .distantPast + let rDate = Self.bedrockBillingDate(from: rhs.date) ?? .distantPast + if lDate != rDate { return lDate < rDate } + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost < rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens < rTokens } + return lhs.date < rhs.date + } + } + + private static func bedrockDisplayDate(from text: String) -> String? { + guard let date = bedrockBillingDate(from: text) else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "MMM d" + return formatter.string(from: date) + } + + private static func bedrockBillingDate(from text: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + static func providerCostSection( + provider: UsageProvider, + cost: ProviderCostSnapshot?) -> ProviderCostSection? + { + if provider == .manus { + return nil + } + guard let cost else { return nil } + guard provider != .synthetic else { return nil } + + if provider == .factory, cost.period == "Extra usage balance" { + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + return ProviderCostSection( + title: "Extra usage", + percentUsed: nil, + spendLine: "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", + percentUsed: nil, + spendLine: "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" + return ProviderCostSection( + title: "API spend", + percentUsed: nil, + spendLine: "\(periodLabel): \(spend)", + percentLine: nil) + } + + guard cost.limit > 0 else { return nil } + + let used: String + let limit: String + let title: String + + if cost.currencyCode == "Quota" { + title = "Quota usage" + used = String(format: "%.0f", cost.used) + limit = String(format: "%.0f", cost.limit) + } else { + title = "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" + + return ProviderCostSection( + title: title, + percentUsed: percentUsed, + spendLine: "\(periodLabel): \(used) / \(limit)", + percentLine: String(format: "%.0f%% used", min(100, max(0, percentUsed)))) + } + + static func clamped(_ value: Double) -> Double { + min(100, max(0, value)) + } +} diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift new file mode 100644 index 000000000..f7bea3382 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -0,0 +1,42 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func kiroUsageNotes(input: Input) -> [String] { + var notes: [String] = [] + if let authMethod = input.snapshot?.loginMethod(for: .kiro)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !authMethod.isEmpty + { + notes.append("Auth: \(authMethod)") + } + if let overages = input.snapshot?.kiroUsage?.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines), + !overages.isEmpty + { + notes.append("Overages: \(overages)") + } + let overagesEnabled = input.snapshot?.kiroUsage?.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("enabled") == true + if overagesEnabled, + let overageCreditsUsed = input.snapshot?.kiroUsage?.overageCreditsUsed + { + notes.append("Overage usage: \(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) credits") + } + if overagesEnabled, + let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD + { + notes.append("Overage cost: \(UsageFormatter.usdString(estimatedOverageCostUSD))") + } + return notes + } + + static func kiroPlan(snapshot: UsageSnapshot?) -> String? { + guard let plan = snapshot?.kiroUsage?.displayPlanName, + !plan.isEmpty + else { return nil } + return plan + } +} diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift new file mode 100644 index 000000000..bf0b57e11 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { + let percentStyle: PercentStyle = .used + let textGenerationCount = services.count { $0.displayName == "Text Generation" } + + 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))" + } else { + service.displayName + } + + return Metric( + id: "minimax-service-\(index)", + title: title, + percent: displayPercent, + percentStyle: percentStyle, + resetText: service.resetDescription, + detailText: service.timeRange, + detailLeftText: usageLabel, + detailRightText: usedLabel, + pacePercent: nil, + paceOnTop: true, + cardStyle: true) + } + } + + private static func displayWindowBadge(for windowType: String) -> String { + let trimmed = windowType.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.lowercased() + + if normalized == "weekly" { + return "Weekly" + } + if normalized == "5 hours" || normalized == "5 hour" || normalized == "5h" { + return "5h" + } + if normalized == "today" { + return "Today" + } + if normalized == "daily" { + return "Daily" + } + return trimmed.isEmpty ? windowType : trimmed + } +} diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift new file mode 100644 index 000000000..3efe36679 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -0,0 +1,131 @@ +import CodexBarCore +import SwiftUI + +extension UsageMenuCardView.Model { + struct PaceDetail { + let leftLabel: String + let rightLabel: String? + let pacePercent: Double? + let paceOnTop: Bool + } + + var isOverviewErrorOnly: Bool { + self.subtitleStyle == .error && + self.metrics.isEmpty && + self.usageNotes.isEmpty && + self.openAIAPIUsage == nil && + self.inlineUsageDashboard == nil && + self.creditsRemaining == nil && + self.providerCost == nil && + self.tokenUsage == nil && + self.placeholder == nil + } + + var hasUsageContent: Bool { + !self.metrics.isEmpty || + !self.usageNotes.isEmpty || + self.openAIAPIUsage != nil || + self.inlineUsageDashboard != nil || + self.placeholder != nil + } + + static func progressColor(for provider: UsageProvider) -> Color { + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } + + static func resetText( + for window: RateWindow, + style: ResetTimeDisplayStyle, + now: Date) -> String? + { + UsageFormatter.resetLine(for: window, style: style, now: now) + } + + static func placeholder(input: Input) -> String? { + if self.shouldShowRateLimitsUnavailablePlaceholder(input: input) { + return "Limits not available" + } + + if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { + return "No usage yet" + } + + return nil + } + + static func lastError(input: Input) -> String? { + guard let lastError = input.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), + !lastError.isEmpty + else { + return nil + } + if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) { + return nil + } + return lastError + } + + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { + let currentError = lastError ?? input.lastError + if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), + !currentError.isEmpty, + !UsageError.isNoRateLimitsFoundDescription(currentError) + { + return false + } + return self.rateLimitsUnavailable(input: input, lastError: currentError) + } + + private static func rateLimitsUnavailable(input: Input, lastError: String? = nil) -> Bool { + UsageLimitsAvailability.resolve( + provider: input.provider, + snapshot: input.snapshot, + account: input.account, + lastErrorDescription: lastError ?? input.lastError) + .isUnavailable + } + + static func sessionPaceDetail( + provider: UsageProvider, + window: RateWindow, + now: Date, + showUsed: Bool) -> PaceDetail? + { + guard let detail = UsagePaceText.sessionDetail(provider: provider, window: window, now: now) else { return nil } + let expectedUsed = detail.expectedUsedPercent + let actualUsed = window.usedPercent + let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) + let actualPercent = showUsed ? actualUsed : (100 - actualUsed) + if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } + let paceOnTop = actualUsed <= expectedUsed + let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } + return PaceDetail( + leftLabel: detail.leftLabel, + rightLabel: detail.rightLabel, + pacePercent: pacePercent, + paceOnTop: paceOnTop) + } + + static func weeklyPaceDetail( + window: RateWindow, + now: Date, + pace: UsagePace?, + showUsed: Bool) -> PaceDetail? + { + guard let pace else { return nil } + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + let expectedUsed = detail.expectedUsedPercent + let actualUsed = window.usedPercent + let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) + let actualPercent = showUsed ? actualUsed : (100 - actualUsed) + if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } + let paceOnTop = actualUsed <= expectedUsed + let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } + return PaceDetail( + leftLabel: detail.leftLabel, + rightLabel: detail.rightLabel, + pacePercent: pacePercent, + paceOnTop: paceOnTop) + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c97aed64b..ceadf0f57 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -36,6 +36,8 @@ struct UsageMenuCardView: View { let detailRightText: String? let pacePercent: Double? let paceOnTop: Bool + let warningMarkerPercents: [Double] + let cardStyle: Bool init( id: String, @@ -48,7 +50,9 @@ struct UsageMenuCardView: View { detailLeftText: String?, detailRightText: String?, pacePercent: Double?, - paceOnTop: Bool) + paceOnTop: Bool, + warningMarkerPercents: [Double] = [], + cardStyle: Bool = false) { self.id = id self.title = title @@ -61,6 +65,8 @@ struct UsageMenuCardView: View { self.detailRightText = detailRightText self.pacePercent = pacePercent self.paceOnTop = paceOnTop + self.warningMarkerPercents = warningMarkerPercents + self.cardStyle = cardStyle } var percentLabel: String { @@ -84,8 +90,9 @@ struct UsageMenuCardView: View { struct ProviderCostSection { let title: String - let percentUsed: Double + let percentUsed: Double? let spendLine: String + let percentLine: String? } let provider: UsageProvider @@ -96,6 +103,8 @@ struct UsageMenuCardView: View { let planText: String? let metrics: [Metric] let usageNotes: [String] + let openAIAPIUsage: OpenAIAPIUsageSnapshot? + let inlineUsageDashboard: InlineUsageDashboardModel? let creditsText: String? let creditsRemaining: Double? let creditsHintText: String? @@ -126,7 +135,9 @@ struct UsageMenuCardView: View { } if self.model.metrics.isEmpty { - if !self.model.usageNotes.isEmpty { + if let dashboard = self.model.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { Text(placeholder) @@ -134,7 +145,7 @@ struct UsageMenuCardView: View { .font(.subheadline) } } else { - let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty + let hasUsage = self.model.hasUsageContent let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasCost = self.model.tokenUsage != nil || hasProviderCost @@ -148,7 +159,9 @@ struct UsageMenuCardView: View { title: Self.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } - if !self.model.usageNotes.isEmpty { + if let dashboard = self.model.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } } @@ -177,7 +190,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text("cost_header_estimated") .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -214,7 +227,7 @@ struct UsageMenuCardView: View { } private var hasDetails: Bool { - !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || + self.model.hasUsageContent || self.model.tokenUsage != nil || self.model.providerCost != nil } @@ -227,13 +240,13 @@ private struct UsageMenuCardHeaderView: View { var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { - Text(self.model.providerName) - .font(.headline) + Text(self.model.providerName).font(.headline) .fontWeight(.semibold) + .lineLimit(1).truncationMode(.tail).layoutPriority(1) Spacer() - Text(self.model.email) - .font(.subheadline) + Text(self.model.email).font(.subheadline) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1).truncationMode(.middle) } let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline HStack(alignment: subtitleAlignment) { @@ -330,17 +343,21 @@ private struct ProviderCostContent: View { Text(self.section.title) .font(.body) .fontWeight(.medium) - UsageProgressBar( - percent: self.section.percentUsed, - tint: self.progressColor, - accessibilityLabel: "Extra usage spent") + if let percentUsed = self.section.percentUsed { + UsageProgressBar( + percent: percentUsed, + tint: self.progressColor, + accessibilityLabel: "Extra usage spent") + } HStack(alignment: .firstTextBaseline) { Text(self.section.spendLine) .font(.footnote) Spacer() - Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed)))) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + if let percentLine = self.section.percentLine { + Text(percentLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } } } } @@ -368,7 +385,8 @@ private struct MetricRow: View { tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + paceOnTop: self.metric.paceOnTop, + warningMarkerPercents: self.metric.warningMarkerPercents) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) @@ -410,6 +428,9 @@ private struct MetricRow: View { } } .frame(maxWidth: .infinity, alignment: .leading) + .padding(self.metric.cardStyle ? 10 : 0) + .background(self.metric.cardStyle ? Color.secondary.opacity(self.isHighlighted ? 0.2 : 0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: self.metric.cardStyle ? 10 : 0)) } } @@ -461,7 +482,9 @@ struct UsageMenuCardUsageSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { if self.model.metrics.isEmpty { - if !self.model.usageNotes.isEmpty { + if let dashboard = self.model.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } else if let placeholder = self.model.placeholder { Text(placeholder) @@ -475,7 +498,9 @@ struct UsageMenuCardUsageSectionView: View { title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), progressColor: self.model.progressColor) } - if !self.model.usageNotes.isEmpty { + if let dashboard = self.model.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } else if !self.model.usageNotes.isEmpty { UsageNotesContent(notes: self.model.usageNotes) } } @@ -589,7 +614,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("Cost") + Text("cost_header_estimated") .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -670,7 +695,9 @@ extension UsageMenuCardView.Model { let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool + let claudePeakHoursEnabled: Bool let weeklyPace: UsagePace? + let quotaWarningThresholds: [QuotaWarningWindow: [Int]] let now: Date init( @@ -694,7 +721,9 @@ extension UsageMenuCardView.Model { sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, + claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, + quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], now: Date) { self.provider = provider @@ -717,7 +746,9 @@ extension UsageMenuCardView.Model { self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo + self.claudePeakHoursEnabled = claudePeakHoursEnabled self.weeklyPace = weeklyPace + self.quotaWarningThresholds = quotaWarningThresholds self.now = now } } @@ -729,6 +760,8 @@ extension UsageMenuCardView.Model { account: input.account, metadata: input.metadata) let metrics = Self.metrics(input: input) + let openAIAPIUsage = input.snapshot?.openAIAPIUsage + let inlineUsageDashboard = Self.inlineUsageDashboard(input: input) let usageNotes = Self.usageNotes(input: input) let creditsText: String? = if input.provider == .openrouter { nil @@ -737,7 +770,15 @@ extension UsageMenuCardView.Model { } else { Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) } - let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { + let isClaudeAdminAPI = input.provider == .claude && + input.snapshot?.identity?.loginMethod == "Admin API" + let hidesOptionalProviderCost = ((input.provider == .claude && !isClaudeAdminAPI) || + input.provider == .factory || + input.provider == .opencodego) && + !input.showOptionalCreditsAndExtraUsage + let providerCost: ProviderCostSection? = if hidesOptionalProviderCost || + (input.provider == .openai && openAIAPIUsage != nil) + { nil } else { Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) @@ -750,9 +791,10 @@ extension UsageMenuCardView.Model { let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, - lastError: input.lastError) + lastError: Self.lastError(input: input), + now: input.now) let redacted = Self.redactedText(input: input, subtitle: subtitle) - let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let placeholder = Self.placeholder(input: input) return UsageMenuCardView.Model( provider: input.provider, @@ -763,6 +805,8 @@ extension UsageMenuCardView.Model { planText: planText, metrics: metrics, usageNotes: usageNotes, + openAIAPIUsage: openAIAPIUsage, + inlineUsageDashboard: inlineUsageDashboard, creditsText: creditsText, creditsRemaining: input.credits?.remaining, creditsHintText: redacted.creditsHintText, @@ -774,6 +818,10 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + if input.provider == .kiro { + return kiroUsageNotes(input: input) + } + if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) let resolvedSource = input.sourceLabel? @@ -788,17 +836,54 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .claude, input.claudePeakHoursEnabled { + let peakStatus = ClaudePeakHours.status(at: input.now) + return [peakStatus.label] + } + + 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", + ] + } + + if let notes = apiProviderUsageNotes(input: input) { + return notes + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { return [] } - return switch openRouter.keyQuotaStatus { - case .available: [] - case .noLimitConfigured: ["No limit set for the API key"] - case .unavailable: ["API key limit unavailable right now"] + var notes = Self.openRouterSpendNotes(openRouter) + switch openRouter.keyQuotaStatus { + case .available: + break + case .noLimitConfigured: + notes.append("No limit set for the API key") + case .unavailable: + notes.append("API key limit unavailable right now") + } + return notes + } + + private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { + var parts: [String] = [] + if let daily = usage.keyUsageDaily { + parts.append("Today: \(Self.openRouterCurrencyString(daily))") } + if let weekly = usage.keyUsageWeekly { + parts.append("This week: \(Self.openRouterCurrencyString(weekly))") + } + guard !parts.isEmpty else { return [] } + return [parts.joined(separator: " · ")] + } + + private static func openRouterCurrencyString(_ value: Double) -> String { + String(format: "$%.2f", value) } private static func email( @@ -822,25 +907,34 @@ extension UsageMenuCardView.Model { account: AccountInfo, metadata: ProviderMetadata) -> String? { + if provider == .kiro, + let plan = kiroPlan(snapshot: snapshot) + { + return plan + } if provider == .kilo { guard let pass = self.kiloLoginPass(snapshot: snapshot) else { return nil } - return self.planDisplay(pass) + return self.planDisplay(pass, for: provider) } if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty { - return self.planDisplay(plan) + return self.planDisplay(plan, for: provider) } if metadata.usesAccountFallback, let plan = account.plan, !plan.isEmpty { - return Self.planDisplay(plan) + return Self.planDisplay(plan, for: provider) } return nil } - private static func planDisplay(_ text: String) -> String { - let cleaned = CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) + private static func planDisplay(_ text: String, for provider: UsageProvider) -> String { + let cleaned = if provider == .codex { + CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) + } else { + UsageFormatter.cleanPlanName(text) + } return cleaned.isEmpty ? text : cleaned } @@ -878,7 +972,8 @@ extension UsageMenuCardView.Model { private static func subtitle( snapshot: UsageSnapshot?, isRefreshing: Bool, - lastError: String?) -> (text: String, style: SubtitleStyle) + lastError: String?, + now: Date) -> (text: String, style: SubtitleStyle) { if let lastError, !lastError.isEmpty { return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error) @@ -889,7 +984,7 @@ extension UsageMenuCardView.Model { } if let updated = snapshot?.updatedAt { - return (UsageFormatter.updatedString(from: updated), .info) + return (UsageFormatter.updatedString(from: updated, now: now), .info) } return ("Not fetched yet", .info) @@ -938,6 +1033,13 @@ extension UsageMenuCardView.Model { if input.provider == .antigravity { return Self.antigravityMetrics(input: input, snapshot: snapshot) } + if input.provider == .minimax { + if let minimaxUsage = snapshot.minimaxUsage { + if let services = minimaxUsage.services, !services.isEmpty { + return Self.minimaxMetrics(services: services, input: input) + } + } + } var metrics: [Metric] = [] let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil @@ -945,6 +1047,7 @@ extension UsageMenuCardView.Model { let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) + let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) if input.provider == .codex, let codexProjection = input.codexProjection { metrics.append(contentsOf: Self.codexRateMetrics( input: input, @@ -955,6 +1058,7 @@ extension UsageMenuCardView.Model { input: input, primary: primary, percentStyle: percentStyle, + title: labels.primary, zaiTokenDetail: zaiTokenDetail, openRouterQuotaDetail: openRouterQuotaDetail)) } @@ -963,9 +1067,10 @@ extension UsageMenuCardView.Model { input: input, weekly: weekly, percentStyle: percentStyle, + title: labels.secondary, zaiTimeDetail: zaiTimeDetail)) } - if input.metadata.supportsOpus, let opus = snapshot.tertiary { + if labels.showsTertiary, let opus = snapshot.tertiary { var tertiaryDetailText: String? if input.provider == .alibaba, let detail = opus.resetDescription, @@ -982,7 +1087,7 @@ extension UsageMenuCardView.Model { : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) metrics.append(Metric( id: "tertiary", - title: input.metadata.opusLabel ?? "Sonnet", + title: labels.tertiary, percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, resetText: opusResetText, @@ -990,7 +1095,10 @@ extension UsageMenuCardView.Model { detailLeftText: nil, detailRightText: nil, pacePercent: nil, - paceOnTop: true)) + paceOnTop: true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed))) } if let extraRateWindows = snapshot.extraRateWindows { metrics.append(contentsOf: extraRateWindows.map { namedWindow in @@ -1049,40 +1157,87 @@ extension UsageMenuCardView.Model { return metrics } + private static func rateWindowLabels( + input: Input, + snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) + { + if input.provider == .factory, snapshot.tertiary != nil { + return ("5-hour", "Weekly", "Monthly", true) + } + return ( + input.metadata.sessionLabel, + input.metadata.weeklyLabel, + input.metadata.opusLabel ?? "Sonnet", + input.metadata.supportsOpus) + } + private static func primaryMetric( input: Input, primary: RateWindow, percentStyle: PercentStyle, + title: String? = nil, zaiTokenDetail: String?, openRouterQuotaDetail: String?) -> Metric { var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) + var primaryDetailLeft: String? + var primaryDetailRight: String? + if input.provider == .crof, + let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + primaryDetailRight = detail + } if input.provider == .openrouter, let openRouterQuotaDetail { primaryResetText = openRouterQuotaDetail } - if input.provider == .warp || input.provider == .kilo, + if input.provider == .copilot, + let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + primaryDetailLeft = detail + } + if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail } - if input.provider == .alibaba || input.provider == .mistral, + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + kiroUsage.creditsTotal > 0 + { + let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) + let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) + primaryDetailLeft = "\(remaining) of \(total) credits left" + } + if input.provider == .alibaba || input.provider == .mistral || input.provider == .manus, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { primaryDetailText = detail + if input.provider == .manus { primaryResetText = nil } } - if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { + if [.warp, .kilo, .mimo, .deepseek].contains(input.provider), primary.resetsAt == nil { primaryResetText = nil } // Abacus: show credits as detail, compute pace on the primary monthly window - var primaryDetailLeft: String? - var primaryDetailRight: String? var primaryPacePercent: Double? var primaryPaceOnTop = true + if let paceDetail = Self.sessionPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) + { + primaryDetailLeft = paceDetail.leftLabel + primaryDetailRight = paceDetail.rightLabel + primaryPacePercent = paceDetail.pacePercent + primaryPaceOnTop = paceDetail.paceOnTop + } if input.provider == .abacus { if let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1118,24 +1273,33 @@ extension UsageMenuCardView.Model { primaryPacePercent = regen.pace.pacePercent primaryPaceOnTop = regen.pace.paceOnTop } + let primaryStatusText = input.provider == .deepseek ? primaryDetailText : nil + if input.provider == .deepseek { + primaryDetailText = nil + } return Metric( id: "primary", - title: input.metadata.sessionLabel, + title: title ?? input.metadata.sessionLabel, percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, + statusText: primaryStatusText, resetText: primaryResetText, detailText: primaryDetailText, detailLeftText: primaryDetailLeft, detailRightText: primaryDetailRight, pacePercent: primaryPacePercent, - paceOnTop: primaryPaceOnTop) + paceOnTop: primaryPaceOnTop, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: input.usageBarsShowUsed)) } private static func secondaryMetric( input: Input, weekly: RateWindow, percentStyle: PercentStyle, + title: String? = nil, zaiTimeDetail: String?) -> Metric { var paceDetail = Self.weeklyPaceDetail( @@ -1161,12 +1325,43 @@ extension UsageMenuCardView.Model { weeklyResetText = nil } } + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + let remaining = kiroUsage.bonusCreditsRemaining, + let total = kiroUsage.bonusCreditsTotal + { + let remainingText = UsageFormatter.kiroCreditNumber(remaining) + let totalText = UsageFormatter.kiroCreditNumber(total) + paceDetail = PaceDetail( + leftLabel: "\(remainingText) of \(totalText) bonus credits left", + rightLabel: nil, + pacePercent: nil, + paceOnTop: true) + } if input.provider == .alibaba, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { weeklyDetailText = detail } + if input.provider == .manus, + let detail = weekly.resetDescription, + !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + weeklyDetailText = detail + } + if input.provider == .crof, + let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + weeklyResetText = detail + } + if input.provider == .copilot, + let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true) + } // Perplexity bonus credits don't reset; show balance without "Resets" prefix. if input.provider == .perplexity, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1186,7 +1381,7 @@ extension UsageMenuCardView.Model { } return Metric( id: "secondary", - title: input.metadata.weeklyLabel, + title: title ?? input.metadata.weeklyLabel, percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -1194,7 +1389,10 @@ extension UsageMenuCardView.Model { detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true) + paceOnTop: paceDetail?.paceOnTop ?? true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed)) } private static func codexRateMetrics( @@ -1212,7 +1410,11 @@ extension UsageMenuCardView.Model { case .session: title = input.metadata.sessionLabel id = "primary" - paceDetail = nil + paceDetail = Self.sessionPaceDetail( + provider: input.provider, + window: window, + now: input.now, + showUsed: input.usageBarsShowUsed) case .weekly: title = input.metadata.weeklyLabel id = "secondary" @@ -1233,7 +1435,10 @@ extension UsageMenuCardView.Model { detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true) + paceOnTop: paceDetail?.paceOnTop ?? true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed)) } } @@ -1328,35 +1533,6 @@ extension UsageMenuCardView.Model { return "\(remaining)/\(limit) left" } - private struct PaceDetail { - let leftLabel: String - let rightLabel: String? - let pacePercent: Double? - let paceOnTop: Bool - } - - private static func weeklyPaceDetail( - window: RateWindow, - now: Date, - pace: UsagePace?, - showUsed: Bool) -> PaceDetail? - { - guard let pace else { return nil } - let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) - let expectedUsed = detail.expectedUsedPercent - let actualUsed = window.usedPercent - let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) - let actualPercent = showUsed ? actualUsed : (100 - actualUsed) - if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } - let paceOnTop = actualUsed <= expectedUsed - let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } - return PaceDetail( - leftLabel: detail.leftLabel, - rightLabel: detail.rightLabel, - pacePercent: pacePercent, - paceOnTop: paceOnTop) - } - private static func syntheticRegenDetail( weekly: RateWindow, cost: ProviderCostSnapshot?, @@ -1420,149 +1596,8 @@ extension UsageMenuCardView.Model { return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) } - private static func creditsLine( - metadata: ProviderMetadata, - credits: CreditsSnapshot?, - error: String?) -> String? - { - guard metadata.supportsCredits else { return nil } - if let credits { - return UsageFormatter.creditsString(from: credits.remaining) - } - if let error, !error.isEmpty { - return error.trimmingCharacters(in: .whitespacesAndNewlines) - } - return metadata.creditsHint - } - private static func dashboardHint(error: String?) -> String? { guard let error, !error.isEmpty else { return nil } return error } - - private static func tokenUsageSection( - provider: UsageProvider, - enabled: Bool, - snapshot: CostUsageTokenSnapshot?, - error: String?) -> TokenUsageSection? - { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - guard enabled else { return nil } - guard let snapshot else { return nil } - - let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } - let sessionLine: String = { - if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" - } - return "Today: \(sessionCost)" - }() - - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) - let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) - let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let monthLine: String = { - if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" - } - return "Last 30 days: \(monthCost)" - }() - let err = (error?.isEmpty ?? true) ? nil : error - return TokenUsageSection( - sessionLine: sessionLine, - monthLine: monthLine, - hintLine: nil, - errorLine: err, - errorCopyText: (error?.isEmpty ?? true) ? nil : error) - } - - private static func providerCostSection( - provider: UsageProvider, - cost: ProviderCostSnapshot?) -> ProviderCostSection? - { - guard let cost else { return nil } - guard cost.limit > 0 else { return nil } - guard provider != .synthetic else { return nil } - - let used: String - let limit: String - let title: String - - if cost.currencyCode == "Quota" { - title = "Quota usage" - used = String(format: "%.0f", cost.used) - limit = String(format: "%.0f", cost.limit) - } else { - title = "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" - - return ProviderCostSection( - title: title, - percentUsed: percentUsed, - spendLine: "\(periodLabel): \(used) / \(limit)") - } - - private static func clamped(_ value: Double) -> Double { - min(100, max(0, value)) - } - - private static func progressColor(for provider: UsageProvider) -> Color { - let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) - } - - private static func resetText( - for window: RateWindow, - style: ResetTimeDisplayStyle, - now: Date) -> String? - { - UsageFormatter.resetLine(for: window, style: style, now: now) - } -} - -// MARK: - Copy-on-click overlay - -private struct ClickToCopyOverlay: NSViewRepresentable { - let copyText: String - - func makeNSView(context: Context) -> ClickToCopyView { - ClickToCopyView(copyText: self.copyText) - } - - func updateNSView(_ nsView: ClickToCopyView, context: Context) { - nsView.copyText = self.copyText - } -} - -private final class ClickToCopyView: NSView { - var copyText: String - - init(copyText: String) { - self.copyText = copyText - super.init(frame: .zero) - self.wantsLayer = false - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - _ = event - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(self.copyText, forType: .string) - } } diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 1f27ef5c1..b9204b1fb 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -43,10 +43,13 @@ struct MenuContent: View { switch style { case .headline: Text(text).font(.headline) + .accessibilityLabel(text) case .primary: Text(text) + .accessibilityLabel(text) case .secondary: Text(text).foregroundStyle(.secondary).font(.footnote) + .accessibilityLabel(text) } case let .action(title, action): Button { @@ -60,8 +63,11 @@ struct MenuContent: View { Text(title) } .foregroundStyle(.primary) + .accessibilityElement(children: .combine) + .accessibilityLabel(title) } else { Text(title) + .accessibilityLabel(title) } } .buttonStyle(.plain) @@ -73,6 +79,8 @@ struct MenuContent: View { } Text(title).font(.headline) } + .accessibilityElement(children: .combine) + .accessibilityLabel(title) ForEach(Array(submenuItems.enumerated()), id: \.offset) { _, submenuItem in HStack(spacing: 8) { if submenuItem.isChecked { @@ -85,6 +93,8 @@ struct MenuContent: View { Text(submenuItem.title) .foregroundStyle(submenuItem.isEnabled ? .primary : .secondary) } + .accessibilityElement(children: .combine) + .accessibilityLabel(submenuItem.title) } } case .divider: @@ -108,10 +118,14 @@ struct MenuContent: View { self.actions.openDashboard() case .statusPage: self.actions.openStatusPage() + case .changelog: + self.actions.openChangelog() case .addCodexAccount: self.actions.addCodexAccount() case .requestCodexSystemPromotion: return + case let .addProviderAccount(provider): + self.actions.switchAccount(provider) case let .switchAccount(provider): self.actions.switchAccount(provider) case let .openTerminal(command): @@ -138,6 +152,7 @@ struct MenuActions { let refreshAugmentSession: () -> Void let openDashboard: () -> Void let openStatusPage: () -> Void + let openChangelog: () -> Void let addCodexAccount: () -> Void let switchAccount: (UsageProvider) -> Void let openTerminal: (String) -> Void @@ -156,6 +171,27 @@ struct StatusIconView: View { Image(nsImage: self.icon) .renderingMode(.template) .interpolation(.none) + .accessibilityLabel(self.accessibilityLabel) + .accessibilityValue(self.accessibilityValue) + } + + private var accessibilityLabel: String { + let descriptor = ProviderDescriptorRegistry.descriptor(for: self.provider) + return descriptor.metadata.displayName + } + + private var accessibilityValue: String { + let snapshot = self.store.snapshot(for: self.provider) + guard let snap = snapshot else { + return "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 stale = self.store.isStale(provider: self.provider) + return stale ? "\(percent), stale data" : percent } private var icon: NSImage { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 46e0a2c6a..0283a73a4 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -32,6 +32,7 @@ struct MenuDescriptor { case refresh = "arrow.clockwise" case dashboard = "chart.bar" case statusPage = "waveform.path.ecg" + case changelog = "list.bullet.rectangle" case addAccount = "plus" case systemAccount = "person.crop.circle" case switchAccount = "key" @@ -55,8 +56,10 @@ struct MenuDescriptor { case refreshAugmentSession case dashboard case statusPage + case changelog case addCodexAccount case requestCodexSystemPromotion(UUID) + case addProviderAccount(UsageProvider) case switchAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) @@ -145,9 +148,13 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle + let labels = Self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snap) if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus { - // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail + let primaryWindow = if provider == .warp || provider == .kilo || provider == .mimo || provider == + .abacus || + provider == .deepseek + { + // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, @@ -159,11 +166,19 @@ struct MenuDescriptor { } Self.appendRateWindow( entries: &entries, - title: meta.sessionLabel, + title: labels.primary, window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo || provider == .abacus, + if provider == .warp || provider == .kilo || provider == .mimo || provider == .abacus || provider == + .deepseek, + let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + entries.append(.text(detail, .secondary)) + } + if provider == .crof, + primary.resetsAt != nil, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { @@ -175,10 +190,14 @@ struct MenuDescriptor { let paceSummary = UsagePaceText.weeklySummary(pace: pace) entries.append(.text(paceSummary, .secondary)) } + if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) { + entries.append(.text(paceSummary, .secondary)) + } } if let weekly = snap.secondary { let weeklyResetOverride: String? = { - guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil } + guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof + else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) guard let detail, !detail.isEmpty else { return nil } if provider == .kilo, weekly.resetsAt != nil { @@ -188,7 +207,7 @@ struct MenuDescriptor { }() Self.appendRateWindow( entries: &entries, - title: meta.weeklyLabel, + title: labels.secondary, window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, @@ -205,27 +224,21 @@ struct MenuDescriptor { entries.append(.text(paceSummary, .secondary)) } } - if meta.supportsOpus, let opus = snap.tertiary { + if labels.showsTertiary, let opus = snap.tertiary { // Perplexity purchased credits don't reset; show the balance as plain text. let opusResetOverride: String? = provider == .perplexity ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) : nil Self.appendRateWindow( entries: &entries, - title: meta.opusLabel ?? "Sonnet", + title: labels.tertiary, window: opus, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, resetOverride: opusResetOverride) } - if let cost = snap.providerCost { - 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)) - } - } + Self.appendProviderUsageSummaries(entries: &entries, snapshot: snap) } else { entries.append(.text("No usage yet", .secondary)) } @@ -242,6 +255,130 @@ struct MenuDescriptor { return Section(entries: entries) } + private static func appendProviderUsageSummaries( + entries: inout [Entry], + snapshot: UsageSnapshot) + { + if let cost = snapshot.providerCost { + 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)) + } + } + if let openAIAPIUsage = snapshot.openAIAPIUsage { + Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage) + } + if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage { + Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage) + } + if let openRouterUsage = snapshot.openRouterUsage { + Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage) + } + if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { + Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) + } + } + + private static func appendOpenAIAPIUsageSummary( + entries: inout [Entry], + usage: OpenAIAPIUsageSnapshot) + { + let today = usage.latestDay + let last7 = usage.last7Days + let last30 = usage.last30Days + + entries.append(.text( + "Today: \(UsageFormatter.usdString(today.costUSD)) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", + .secondary)) + entries.append(.text( + "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "\(UsageFormatter.tokenCountString(last7.requests)) requests", + .secondary)) + entries.append(.text( + "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + + "\(UsageFormatter.tokenCountString(last30.requests)) requests", + .secondary)) + if let topModel = usage.topModels.first?.name { + entries.append(.text("Top model: \(topModel)", .secondary)) + } + } + + private static func appendClaudeAdminAPIUsageSummary( + entries: inout [Entry], + usage: ClaudeAdminAPIUsageSnapshot) + { + let today = usage.latestDay + let last7 = usage.last7Days + let last30 = usage.last30Days + + entries.append(.text( + "Today: \(UsageFormatter.usdString(today.costUSD)) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", + .secondary)) + entries.append(.text( + "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "\(UsageFormatter.tokenCountString(last7.totalTokens)) tokens", + .secondary)) + entries.append(.text( + "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + + "\(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", + .secondary)) + if let topModel = usage.topModels.first?.name { + entries.append(.text("Top model: \(topModel)", .secondary)) + } + } + + private static func appendOpenRouterUsageSummary( + entries: inout [Entry], + usage: OpenRouterUsageSnapshot) + { + if let daily = usage.keyUsageDaily { + entries.append(.text("Today: \(UsageFormatter.usdString(daily))", .secondary)) + } + if let weekly = usage.keyUsageWeekly { + entries.append(.text("Week: \(UsageFormatter.usdString(weekly))", .secondary)) + } + if let monthly = usage.keyUsageMonthly { + entries.append(.text("Month: \(UsageFormatter.usdString(monthly))", .secondary)) + } + } + + private static func appendMistralUsageSummary( + entries: inout [Entry], + usage: MistralUsageSnapshot) + { + 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", + .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", + .secondary)) + if let top = Self.topMistralModel(from: usage.daily) { + entries.append(.text("Top model: \(top)", .secondary)) + } + } + + private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { + var tokens: [String: Int] = [:] + for entry in entries { + for model in entry.models { + tokens[model.name, default: 0] += model.totalTokens + } + } + return tokens.max { + if $0.value == $1.value { return $0.key > $1.key } + return $0.value < $1.value + }?.key + } + private static func accountSection( for provider: UsageProvider, store: UsageStore, @@ -277,16 +414,43 @@ struct MenuDescriptor { if let emailText, !emailText.isEmpty { entries.append(.text("Account: \(redactedEmail)", .secondary)) } - if provider == .kilo { + if provider == .kiro { + if let plan = snapshot?.kiroUsage?.displayPlanName, + !plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + entries.append(.text("Plan: \(plan)", .secondary)) + } + if let loginMethodText, !loginMethodText.isEmpty { + entries.append(.text("Auth: \(loginMethodText)", .secondary)) + } + if let overages = snapshot?.kiroUsage?.overagesStatus, + !overages.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + entries.append(.text("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))", .secondary)) + entries.append(.text("Plan: \(AccountFormatter.plan(pass, provider: provider))", .secondary)) } for detail in kiloLogin.details { entries.append(.text("Activity: \(detail)", .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText))", .secondary)) + if provider == .openrouter || provider == .mimo, + loginMethodText.localizedCaseInsensitiveContains("balance:") + { + let balanceValue = loginMethodText + .replacingOccurrences( + of: #"(?i)^\s*balance:\s*"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let value = balanceValue.isEmpty ? loginMethodText : balanceValue + entries.append(.text("Balance: \(AccountFormatter.plan(value, provider: provider))", .secondary)) + } else { + entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText, provider: provider))", .secondary)) + } } if metadata.usesAccountFallback { @@ -295,7 +459,7 @@ struct MenuDescriptor { entries.append(.text("Account: \(redacted)", .secondary)) } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan))", .secondary)) + entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan, provider: provider))", .secondary)) } } @@ -392,6 +556,9 @@ struct MenuDescriptor { if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { entries.append(.action("Status Page", .statusPage)) } + if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil { + entries.append(.action("Changelog", .changelog)) + } if let statusLine = self.statusLine(for: provider, store: store) { entries.append(.text(statusLine, .secondary)) @@ -452,6 +619,21 @@ struct MenuDescriptor { return false } + private static func rateWindowLabels( + provider: UsageProvider, + metadata: ProviderMetadata, + snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) + { + if provider == .factory, snapshot.tertiary != nil { + return ("5-hour", "Weekly", "Monthly", true) + } + return ( + metadata.sessionLabel, + metadata.weeklyLabel, + metadata.opusLabel ?? "Sonnet", + metadata.supportsOpus) + } + private static func appendRateWindow( entries: inout [Entry], title: String, @@ -482,8 +664,12 @@ struct MenuDescriptor { } private enum AccountFormatter { - static func plan(_ text: String) -> String { - let cleaned = CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) + static func plan(_ text: String, provider: UsageProvider) -> String { + let cleaned = if provider == .codex { + CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) + } else { + UsageFormatter.cleanPlanName(text) + } return cleaned.isEmpty ? text : cleaned } @@ -501,7 +687,8 @@ extension MenuDescriptor.MenuAction { case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue - case .addCodexAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue + case .changelog: MenuDescriptor.MenuActionSystemImage.changelog.rawValue + case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue case .requestCodexSystemPromotion: nil case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue diff --git a/Sources/CodexBar/NotificationSettings.swift b/Sources/CodexBar/NotificationSettings.swift new file mode 100644 index 000000000..cbe3699ba --- /dev/null +++ b/Sources/CodexBar/NotificationSettings.swift @@ -0,0 +1,129 @@ +import AppKit +import Foundation + +struct NotificationDeliverySettings: Equatable, Sendable { + var enabled: Bool + var sound: NotificationSoundOption + + static let localDefault = NotificationDeliverySettings( + enabled: true, + sound: .systemDefault) +} + +enum NotificationSoundOption: String, CaseIterable, Identifiable, Sendable { + case none + case systemDefault + case basso + case blow + case bottle + case frog + case funk + case glass + case hero + case morse + case ping + case pop + case purr + case sosumi + case submarine + case tink + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .none: + "None" + case .systemDefault: + "System Default" + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } + + var systemSoundName: String? { + switch self { + case .none, .systemDefault: + nil + case .basso: + "Basso" + case .blow: + "Blow" + case .bottle: + "Bottle" + case .frog: + "Frog" + case .funk: + "Funk" + case .glass: + "Glass" + case .hero: + "Hero" + case .morse: + "Morse" + case .ping: + "Ping" + case .pop: + "Pop" + case .purr: + "Purr" + case .sosumi: + "Sosumi" + case .submarine: + "Submarine" + case .tink: + "Tink" + } + } +} + +@MainActor +enum NotificationSoundPlayer { + @discardableResult + static func play(_ sound: NotificationSoundOption, volume: Double = 1.0) -> Bool { + guard let name = sound.systemSoundName else { return false } + guard let sound = NSSound(named: NSSound.Name(name)) else { return false } + sound.stop() + sound.volume = Float(min(max(volume, 0.0), 1.0)) + return sound.play() + } +} + +enum AppNotificationEvent: String, CaseIterable, Identifiable, Sendable { + case sessionQuotaDepleted + case sessionQuotaRestored + case providerLogin + case augmentSessionExpired + + var id: String { + self.rawValue + } +} diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 354c63bf1..4d8e4b2f9 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -6,6 +6,7 @@ extension Notification.Name { static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") + static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost") } @MainActor @@ -22,3 +23,18 @@ final class WeeklyLimitResetEvent: NSObject { self.usedPercent = usedPercent } } + +@MainActor +final class QuotaWarningPostedEvent: NSObject { + let provider: UsageProvider + let window: QuotaWarningWindow + let threshold: Int + let postedAt: Date + + init(provider: UsageProvider, window: QuotaWarningWindow, threshold: Int, postedAt: Date) { + self.provider = provider + self.window = window + self.threshold = threshold + self.postedAt = postedAt + } +} diff --git a/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift b/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift new file mode 100644 index 000000000..9804eb9a8 --- /dev/null +++ b/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift @@ -0,0 +1,298 @@ +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: "30d spend", value: UsageFormatter.usdString(model.last30.costUSD)) + StatPill(title: "30d tokens", value: UsageFormatter.tokenCountString(model.last30.totalTokens)) + StatPill(title: "30d 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 + } + + 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) + } + + 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 7112b50ce..072c10cc1 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -146,6 +146,8 @@ struct PlanUtilizationHistoryChartMenuView: View { } .chartLegend(.hidden) .frame(height: Layout.chartHeight) + .accessibilityLabel("Plan utilization chart") + .accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) utilization samples") .chartOverlay { proxy in GeometryReader { geo in MouseLocationReader { location in diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift index 94e478e59..31c3bbe43 100644 --- a/Sources/CodexBar/PreferencesAboutPane.swift +++ b/Sources/CodexBar/PreferencesAboutPane.swift @@ -51,14 +51,14 @@ struct AboutPane: View { VStack(spacing: 2) { Text("CodexBar") .font(.title3).bold() - Text("Version \(self.versionString)") + Text(String(format: L("version_format"), self.versionString)) .foregroundStyle(.secondary) if let buildTimestamp { - Text("Built \(buildTimestamp)") + Text(String(format: L("built_format"), buildTimestamp)) .font(.footnote) .foregroundStyle(.secondary) } - Text("May your tokens never run out—keep agent limits in view.") + Text(L("about_tagline")) .font(.footnote) .foregroundStyle(.secondary) } @@ -66,11 +66,11 @@ struct AboutPane: View { VStack(alignment: .center, spacing: 10) { AboutLinkRow( icon: "chevron.left.slash.chevron.right", - title: "GitHub", + title: L("link_github"), url: "https://github.com/steipete/CodexBar") - AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me") - AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete") - AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me") + AboutLinkRow(icon: "globe", title: L("link_website"), url: "https://steipete.me") + AboutLinkRow(icon: "bird", title: L("link_twitter"), url: "https://twitter.com/steipete") + AboutLinkRow(icon: "envelope", title: L("link_email"), url: "mailto:peter@steipete.me") } .padding(.top, 8) .frame(maxWidth: .infinity) @@ -80,12 +80,12 @@ struct AboutPane: View { if self.updater.isAvailable { VStack(spacing: 10) { - Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled) + Toggle(L("check_updates_auto"), isOn: self.$autoUpdateEnabled) .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .center) VStack(spacing: 6) { HStack(spacing: 12) { - Text("Update Channel") + Text(L("update_channel")) Spacer() Picker("", selection: self.updateChannelBinding) { ForEach(UpdateChannel.allCases) { channel in @@ -102,14 +102,14 @@ struct AboutPane: View { .multilineTextAlignment(.center) .frame(maxWidth: 280) } - Button("Check for Updates…") { self.updater.checkForUpdates(nil) } + Button(L("check_for_updates")) { self.updater.checkForUpdates(nil) } } } else { - Text(self.updater.unavailableReason ?? "Updates unavailable in this build.") + Text(self.updater.unavailableReason ?? L("updates_unavailable")) .foregroundStyle(.secondary) } - Text("© 2026 Peter Steinberger. MIT License.") + Text(L("copyright")) .font(.footnote) .foregroundStyle(.secondary) .padding(.top, 4) diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 9b901c9d8..e2f0b3657 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -11,17 +11,17 @@ struct AdvancedPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 8) { - Text("Keyboard shortcut") + Text(L("section_keyboard_shortcut")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) HStack(alignment: .center, spacing: 12) { - Text("Open menu") + Text(L("open_menu_shortcut_title")) .font(.body) Spacer() KeyboardShortcuts.Recorder(for: .openMenu) } - Text("Trigger the menu bar menu from anywhere.") + Text(L("open_menu_shortcut_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -36,7 +36,7 @@ struct AdvancedPane: View { if self.isInstallingCLI { ProgressView().controlSize(.small) } else { - Text("Install CLI") + Text(L("install_cli")) } } .disabled(self.isInstallingCLI) @@ -48,7 +48,7 @@ struct AdvancedPane: View { .lineLimit(2) } } - Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.") + Text(L("install_cli_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -57,16 +57,16 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Show Debug Settings", - subtitle: "Expose troubleshooting tools in the Debug tab.", + title: L("show_debug_settings_title"), + subtitle: L("show_debug_settings_subtitle"), binding: self.$settings.debugMenuEnabled) PreferenceToggleRow( - title: "Surprise me", - subtitle: "Check if you like your agents having some fun up there.", + title: L("surprise_me_title"), + subtitle: L("surprise_me_subtitle"), binding: self.$settings.randomBlinkEnabled) PreferenceToggleRow( - title: "Weekly limit confetti", - subtitle: "Play full-screen confetti when weekly usage resets.", + title: L("weekly_limit_confetti_title"), + subtitle: L("weekly_limit_confetti_subtitle"), binding: self.$settings.confettiOnWeeklyLimitResetsEnabled) } @@ -74,24 +74,26 @@ struct AdvancedPane: View { SettingsSection(contentSpacing: 10) { PreferenceToggleRow( - title: "Hide personal information", - subtitle: "Obscure email addresses in the menu bar and menu UI.", + title: L("hide_personal_info_title"), + subtitle: L("hide_personal_info_subtitle"), binding: self.$settings.hidePersonalInfo) + PreferenceToggleRow( + title: L("show_provider_storage_usage_title"), + subtitle: L("show_provider_storage_usage_subtitle"), + binding: self.$settings.providerStorageFootprintsEnabled) } Divider() SettingsSection( - title: "Keychain access", - caption: """ - Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \ - headers manually in Providers. - """) { - PreferenceToggleRow( - title: "Disable Keychain access", - subtitle: "Prevents any Keychain access while enabled.", - binding: self.$settings.debugDisableKeychainAccess) - } + title: L("section_keychain_access"), + caption: L("keychain_access_caption")) + { + PreferenceToggleRow( + title: L("disable_keychain_access_title"), + subtitle: L("disable_keychain_access_subtitle"), + binding: self.$settings.debugDisableKeychainAccess) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) @@ -109,7 +111,7 @@ extension AdvancedPane { let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI") let fm = FileManager.default guard fm.fileExists(atPath: helperURL.path) else { - self.cliStatus = "CodexBarCLI not found in app bundle." + self.cliStatus = L("cli_not_found") return } @@ -145,7 +147,7 @@ extension AdvancedPane { } self.cliStatus = results.isEmpty - ? "No writable bin dirs found." + ? L("no_writable_bin_dirs") : results.joined(separator: " · ") } diff --git a/Sources/CodexBar/PreferencesCodexAccountsSection.swift b/Sources/CodexBar/PreferencesCodexAccountsSection.swift index 4b09898c2..c8d541433 100644 --- a/Sources/CodexBar/PreferencesCodexAccountsSection.swift +++ b/Sources/CodexBar/PreferencesCodexAccountsSection.swift @@ -290,13 +290,20 @@ private struct CodexAccountsSectionRowView: View { var body: some View { HStack(alignment: .center, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(self.account.displayName) - .font(.subheadline.weight(.semibold)) - if self.showsSystemBadge { - Text("(System)") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(self.account.displayName) + .font(.subheadline.weight(.semibold)) + if self.showsSystemBadge { + Text("(System)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + if let health = self.account.authenticationHealthLabel { + Text(health) + .font(.caption) + .foregroundStyle(.orange) } } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..08e9d2de0 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -13,6 +13,7 @@ struct DebugPane: View { @State private var logText: String = "" @State private var isClearingCostCache = false @State private var costCacheStatus: String? + @State private var cookieCacheStatus: String? #if DEBUG @State private var currentErrorProvider: UsageProvider = .codex @State private var simulatedErrorText: String = """ @@ -26,10 +27,10 @@ struct DebugPane: View { var body: some View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 20) { - SettingsSection(title: "Logging") { + SettingsSection(title: L("section_logging")) { PreferenceToggleRow( - title: "Enable file logging", - subtitle: "Write logs to \(self.fileLogPath) for debugging.", + title: L("enable_file_logging"), + subtitle: String(format: L("enable_file_logging_subtitle"), self.fileLogPath), binding: self.$debugFileLoggingEnabled) .onChange(of: self.debugFileLoggingEnabled) { _, newValue in if self.settings.debugFileLoggingEnabled != newValue { @@ -39,9 +40,9 @@ struct DebugPane: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Verbosity") + Text(L("verbosity_title")) .font(.body) - Text("Controls how much detail is logged.") + Text(L("verbosity_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -59,31 +60,31 @@ struct DebugPane: View { Button { NSWorkspace.shared.open(CodexBarLog.fileLogURL) } label: { - Label("Open log file", systemImage: "doc.text.magnifyingglass") + Label(L("open_log_file"), systemImage: "doc.text.magnifyingglass") } .controlSize(.small) } SettingsSection { PreferenceToggleRow( - title: "Force animation on next refresh", - subtitle: "Temporarily shows the loading animation after the next refresh.", + title: L("force_animation_next_refresh"), + subtitle: L("force_animation_next_refresh_subtitle"), binding: self.$store.debugForceAnimation) } SettingsSection( - title: "Loading animations", - caption: "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior.") + title: L("section_loading_animations"), + caption: L("loading_animations_caption")) { Picker("Animation pattern", selection: self.animationPatternBinding) { - Text("Random (default)").tag(nil as LoadingPattern?) + Text(L("animation_random_default")).tag(nil as LoadingPattern?) ForEach(LoadingPattern.allCases) { pattern in Text(pattern.displayName).tag(Optional(pattern)) } } .pickerStyle(.radioGroup) - Button("Replay selected animation") { + Button(L("replay_selected_animation")) { self.replaySelectedAnimation() } .keyboardShortcut(.defaultAction) @@ -91,14 +92,14 @@ struct DebugPane: View { Button { NotificationCenter.default.post(name: .codexbarDebugBlinkNow, object: nil) } label: { - Label("Blink now", systemImage: "eyes") + Label(L("blink_now"), systemImage: "eyes") } .controlSize(.small) } SettingsSection( - title: "Probe logs", - caption: "Fetch the latest probe output for debugging; Copy keeps the full text.") + title: L("section_probe_logs"), + caption: L("probe_logs_caption")) { Picker("Provider", selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) @@ -113,23 +114,23 @@ struct DebugPane: View { HStack(spacing: 12) { Button { self.loadLog(self.currentLogProvider) } label: { - Label("Fetch log", systemImage: "arrow.clockwise") + Label(L("fetch_log"), systemImage: "arrow.clockwise") } .disabled(self.isLoadingLog) Button { self.copyToPasteboard(self.logText) } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(L("copy"), systemImage: "doc.on.doc") } .disabled(self.logText.isEmpty) Button { self.saveLog(self.currentLogProvider) } label: { - Label("Save to file", systemImage: "externaldrive.badge.plus") + Label(L("save_to_file"), systemImage: "externaldrive.badge.plus") } .disabled(self.isLoadingLog && self.logText.isEmpty) if self.currentLogProvider == .claude { Button { self.loadClaudeDump() } label: { - Label("Load parse dump", systemImage: "doc.text.magnifyingglass") + Label(L("load_parse_dump"), systemImage: "doc.text.magnifyingglass") } .disabled(self.isLoadingLog) } @@ -139,7 +140,7 @@ struct DebugPane: View { self.settings.rerunProviderDetection() self.loadLog(self.currentLogProvider) } label: { - Label("Re-run provider autodetect", systemImage: "dot.radiowaves.left.and.right") + Label(L("rerun_provider_autodetect"), systemImage: "dot.radiowaves.left.and.right") } .controlSize(.small) @@ -165,8 +166,8 @@ struct DebugPane: View { } SettingsSection( - title: "Fetch strategy attempts", - caption: "Last fetch pipeline decisions and errors for a provider.") + title: L("section_fetch_strategy"), + caption: L("fetch_strategy_caption")) { Picker("Provider", selection: self.$currentFetchProvider) { ForEach(UsageProvider.allCases, id: \.self) { provider in @@ -190,14 +191,14 @@ struct DebugPane: View { if !self.settings.debugDisableKeychainAccess { SettingsSection( - title: "OpenAI cookies", - caption: "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt.") + title: L("section_openai_cookies"), + caption: L("openai_cookies_caption")) { HStack(spacing: 12) { Button { self.copyToPasteboard(self.store.openAIDashboardCookieImportDebugLog ?? "") } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(L("copy"), systemImage: "doc.on.doc") } .disabled((self.store.openAIDashboardCookieImportDebugLog ?? "").isEmpty) } @@ -206,7 +207,7 @@ struct DebugPane: View { Text( self.store.openAIDashboardCookieImportDebugLog?.isEmpty == false ? (self.store.openAIDashboardCookieImportDebugLog ?? "") - : "No log yet. Update OpenAI cookies in Providers → Codex to run an import.") + : L("no_log_yet")) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -219,8 +220,8 @@ struct DebugPane: View { } SettingsSection( - title: "Caches", - caption: "Clear cached cost scan results.") + title: L("section_caches"), + caption: L("caches_caption")) { let isTokenRefreshActive = self.store.isTokenRefreshInFlight(for: .codex) || self.store.isTokenRefreshInFlight(for: .claude) @@ -229,7 +230,7 @@ struct DebugPane: View { Button { Task { await self.clearCostCache() } } label: { - Label("Clear cost cache", systemImage: "trash") + Label(L("clear_cost_cache"), systemImage: "trash") } .disabled(self.isClearingCostCache || isTokenRefreshActive) @@ -239,11 +240,25 @@ struct DebugPane: View { .foregroundStyle(.tertiary) } } + + HStack(spacing: 12) { + Button { + self.clearCookieCache() + } label: { + Label(L("clear_cookie_cache"), systemImage: "trash") + } + + if let status = self.cookieCacheStatus { + Text(status) + .font(.footnote) + .foregroundStyle(.tertiary) + } + } } SettingsSection( - title: "Notifications", - caption: "Trigger test notifications for the 5-hour session window (depleted/restored).") + title: L("section_notifications"), + caption: L("notifications_caption")) { Picker("Provider", selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) @@ -256,26 +271,26 @@ struct DebugPane: View { Button { self.postSessionNotification(.depleted, provider: self.currentLogProvider) } label: { - Label("Post depleted", systemImage: "bell.badge") + Label(L("post_depleted"), systemImage: "bell.badge") } .controlSize(.small) Button { self.postSessionNotification(.restored, provider: self.currentLogProvider) } label: { - Label("Post restored", systemImage: "bell") + Label(L("post_restored"), systemImage: "bell") } .controlSize(.small) } } SettingsSection( - title: "CLI sessions", - caption: "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured.") + title: L("section_cli_sessions"), + caption: L("cli_sessions_caption")) { PreferenceToggleRow( - title: "Keep CLI sessions alive", - subtitle: "Skip teardown between probes (debug-only).", + title: L("keep_cli_sessions_alive"), + subtitle: L("keep_cli_sessions_alive_subtitle"), binding: self.$settings.debugKeepCLISessionsAlive) Button { @@ -283,15 +298,15 @@ struct DebugPane: View { await CLIProbeSessionResetter.resetAll() } } label: { - Label("Reset CLI sessions", systemImage: "arrow.counterclockwise") + Label(L("reset_cli_sessions"), systemImage: "arrow.counterclockwise") } .controlSize(.small) } #if DEBUG SettingsSection( - title: "Error simulation", - caption: "Inject a fake error message into the menu card for layout testing.") + title: L("section_error_simulation"), + caption: L("error_simulation_caption")) { Picker("Provider", selection: self.$currentErrorProvider) { Text("Codex").tag(UsageProvider.codex) @@ -314,14 +329,14 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set menu error", systemImage: "exclamationmark.triangle") + Label(L("set_menu_error"), systemImage: "exclamationmark.triangle") } .controlSize(.small) Button { self.store._setErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear menu error", systemImage: "xmark.circle") + Label(L("clear_menu_error"), systemImage: "xmark.circle") } .controlSize(.small) } @@ -333,7 +348,7 @@ struct DebugPane: View { self.simulatedErrorText, provider: self.currentErrorProvider) } label: { - Label("Set cost error", systemImage: "banknote") + Label(L("set_cost_error"), systemImage: "banknote") } .controlSize(.small) .disabled(!supportsTokenError) @@ -341,7 +356,7 @@ struct DebugPane: View { Button { self.store._setTokenErrorForTesting(nil, provider: self.currentErrorProvider) } label: { - Label("Clear cost error", systemImage: "xmark.circle") + Label(L("clear_cost_error"), systemImage: "xmark.circle") } .controlSize(.small) .disabled(!supportsTokenError) @@ -350,19 +365,19 @@ struct DebugPane: View { #endif SettingsSection( - title: "CLI paths", - caption: "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout).") + title: L("section_cli_paths"), + caption: L("cli_paths_caption")) { - self.binaryRow(title: "Codex binary", value: self.store.pathDebugInfo.codexBinary) - self.binaryRow(title: "Claude binary", value: self.store.pathDebugInfo.claudeBinary) + self.binaryRow(title: L("codex_binary"), value: self.store.pathDebugInfo.codexBinary) + self.binaryRow(title: L("claude_binary"), value: self.store.pathDebugInfo.claudeBinary) VStack(alignment: .leading, spacing: 6) { - Text("Effective PATH") + Text(L("effective_path")) .font(.callout.weight(.semibold)) ScrollView { Text( self.store.pathDebugInfo.effectivePATH.isEmpty - ? "Unavailable" + ? L("unavailable") : self.store.pathDebugInfo.effectivePATH) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) @@ -376,7 +391,7 @@ struct DebugPane: View { if let loginPATH = self.store.pathDebugInfo.loginShellPATH { VStack(alignment: .leading, spacing: 6) { - Text("Login shell PATH (startup capture)") + Text(L("login_shell_path")) .font(.callout.weight(.semibold)) ScrollView { Text(loginPATH) @@ -422,7 +437,7 @@ struct DebugPane: View { private var displayedLog: String { if self.logText.isEmpty { - return self.isLoadingLog ? "Loading…" : "No log yet. Fetch to load." + return self.isLoadingLog ? L("loading") : L("no_log_yet_fetch") } return self.logText } @@ -472,7 +487,7 @@ struct DebugPane: View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.callout.weight(.semibold)) - Text(value ?? "Not found") + Text(value ?? L("not_found")) .font(.system(.footnote, design: .monospaced)) .foregroundStyle(value == nil ? .secondary : .primary) } @@ -504,12 +519,21 @@ struct DebugPane: View { return } - self.costCacheStatus = "Cleared." + self.costCacheStatus = L("cleared") + } + + private func clearCookieCache() { + let cleared = CookieHeaderCache.clearAll() + if cleared > 0 { + self.cookieCacheStatus = "Cleared \(cleared) provider\(cleared == 1 ? "" : "s")." + } else { + self.cookieCacheStatus = "No cached cookies found." + } } private func fetchAttemptsText(for provider: UsageProvider) -> String { let attempts = self.store.fetchAttempts(for: provider) - guard !attempts.isEmpty else { return "No fetch attempts yet." } + guard !attempts.isEmpty else { return L("no_fetch_attempts") } return attempts.map { attempt in let kind = Self.fetchKindLabel(attempt.kind) var line = "\(attempt.strategyID) (\(kind))" diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 04050b3bb..4628a649a 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -5,6 +5,10 @@ import SwiftUI struct DisplayPane: View { private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit + static func overviewProviderLimitText(limit: Int = Self.maxOverviewProviders) -> String { + L("overview_choose_providers", String(limit)) + } + @State private var isOverviewProviderPopoverPresented = false @Bindable var settings: SettingsStore @Bindable var store: UsageStore @@ -13,35 +17,35 @@ struct DisplayPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("Menu bar") + Text(L("section_menu_bar")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Merge Icons", - subtitle: "Use a single menu bar icon with a provider switcher.", + title: L("merge_icons_title"), + subtitle: L("merge_icons_subtitle"), binding: self.$settings.mergeIcons) PreferenceToggleRow( - title: "Switcher shows icons", - subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).", + title: L("switcher_shows_icons_title"), + subtitle: L("switcher_shows_icons_subtitle"), binding: self.$settings.switcherShowsIcons) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Show most-used provider", - subtitle: "Menu bar auto-shows the provider closest to its rate limit.", + title: L("show_most_used_provider_title"), + subtitle: L("show_most_used_provider_subtitle"), binding: self.$settings.menuBarShowsHighestUsage) .disabled(!self.settings.mergeIcons) .opacity(self.settings.mergeIcons ? 1 : 0.5) PreferenceToggleRow( - title: "Menu bar shows percent", - subtitle: "Replace critter bars with provider branding icons and a percentage.", + title: L("menu_bar_shows_percent_title"), + subtitle: L("menu_bar_shows_percent_subtitle"), binding: self.$settings.menuBarShowsBrandIconWithPercent) HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Display mode") + Text(L("display_mode_title")) .font(.body) - Text("Choose what to show in the menu bar (Pace shows usage vs. expected).") + Text(L("display_mode_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -62,26 +66,48 @@ struct DisplayPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Menu content") + Text(L("section_menu_content")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) PreferenceToggleRow( - title: "Show usage as used", - subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", + title: L("show_usage_as_used_title"), + subtitle: L("show_usage_as_used_subtitle"), binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( - title: "Show reset time as clock", - subtitle: "Display reset times as absolute clock values instead of countdowns.", + title: L("show_quota_warning_markers_title"), + subtitle: L("show_quota_warning_markers_subtitle"), + binding: self.$settings.quotaWarningMarkersVisible) + PreferenceToggleRow( + title: L("show_reset_time_as_clock_title"), + subtitle: L("show_reset_time_as_clock_subtitle"), binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( - title: "Show credits + extra usage", - subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.", - binding: self.$settings.showOptionalCreditsAndExtraUsage) + title: L("show_provider_changelog_links_title"), + subtitle: L("show_provider_changelog_links_subtitle"), + binding: self.$settings.providerChangelogLinksEnabled) PreferenceToggleRow( - title: "Show all token accounts", - subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).", - binding: self.$settings.showAllTokenAccountsInMenu) + title: L("show_credits_extra_usage_title"), + subtitle: L("show_credits_extra_usage_subtitle"), + binding: self.$settings.showOptionalCreditsAndExtraUsage) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("multi_account_layout_title")) + .font(.body) + Text(L("multi_account_layout_subtitle")) + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker(L("multi_account_layout_title"), selection: self.$settings.multiAccountMenuLayout) { + ForEach(MultiAccountMenuLayout.allCases) { layout in + Text(layout.label).tag(layout) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } self.overviewProviderSelector } } @@ -110,11 +136,11 @@ struct DisplayPane: View { private var overviewProviderSelector: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { - Text("Overview tab providers") + Text(L("overview_tab_providers_title")) .font(.body) Spacer(minLength: 0) if self.showsOverviewConfigureButton { - Button("Configure…") { + Button(L("configure")) { self.isOverviewProviderPopoverPresented = true } .offset(y: 1) @@ -125,11 +151,11 @@ struct DisplayPane: View { } if !self.settings.mergeIcons { - Text("Enable Merge Icons to configure Overview tab providers.") + Text(L("overview_enable_merge_icons_hint")) .font(.footnote) .foregroundStyle(.tertiary) } else if self.activeProvidersInOrder.isEmpty { - Text("No enabled providers available for Overview.") + Text(L("overview_no_providers_hint")) .font(.footnote) .foregroundStyle(.tertiary) } else { @@ -144,9 +170,9 @@ struct DisplayPane: View { private var overviewProviderPopover: some View { VStack(alignment: .leading, spacing: 10) { - Text("Choose up to \(Self.maxOverviewProviders) providers") + Text(Self.overviewProviderLimitText()) .font(.headline) - Text("Overview rows always follow provider order.") + Text(L("overview_rows_follow_order")) .font(.footnote) .foregroundStyle(.tertiary) @@ -191,7 +217,7 @@ struct DisplayPane: View { private var overviewProviderSelectionSummary: String { let selectedNames = self.overviewSelectedProviders.map(self.providerDisplayName) - guard !selectedNames.isEmpty else { return "No providers selected" } + guard !selectedNames.isEmpty else { return L("overview_no_providers_selected") } return selectedNames.joined(separator: ", ") } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..edf0a2a00 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -2,6 +2,26 @@ import AppKit import CodexBarCore import SwiftUI +enum AppLanguage: String, CaseIterable, Identifiable { + case system = "" + case english = "en" + case chineseSimplified = "zh-Hans" + case portugueseBrazilian = "pt-BR" + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .system: L("language_system") + case .english: L("language_english") + case .chineseSimplified: L("language_chinese_simplified") + case .portugueseBrazilian: L("language_portuguese_brazilian") + } + } +} + @MainActor struct GeneralPane: View { @Bindable var settings: SettingsStore @@ -11,20 +31,43 @@ struct GeneralPane: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 16) { SettingsSection(contentSpacing: 12) { - Text("System") + Text(L("section_system")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) + + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("language_title")) + .font(.body) + Text(L("language_subtitle")) + .font(.footnote) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Picker(L("language_title"), selection: self.$settings.appLanguage) { + ForEach(AppLanguage.allCases) { option in + Text(option.label).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + } + PreferenceToggleRow( - title: "Start at Login", - subtitle: "Automatically opens CodexBar when you start your Mac.", + title: L("start_at_login_title"), + subtitle: L("start_at_login_subtitle"), binding: self.$settings.launchAtLogin) } Divider() SettingsSection(contentSpacing: 12) { - Text("Usage") + Text(L("section_usage")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -32,18 +75,18 @@ struct GeneralPane: View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 4) { Toggle(isOn: self.$settings.costUsageEnabled) { - Text("Show cost summary") + Text(L("show_cost_summary")) .font(.body) } .toggleStyle(.checkbox) - Text("Reads local usage logs. Shows today + last 30 days cost in the menu.") + Text(L("show_cost_summary_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) if self.settings.costUsageEnabled { - Text("Auto-refresh: hourly · Timeout: 10m") + Text(L("cost_auto_refresh_info")) .font(.footnote) .foregroundStyle(.tertiary) @@ -57,16 +100,16 @@ struct GeneralPane: View { Divider() SettingsSection(contentSpacing: 12) { - Text("Automation") + Text(L("section_automation")) .font(.caption) .foregroundStyle(.secondary) .textCase(.uppercase) VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Refresh cadence") + Text(L("refresh_cadence_title")) .font(.body) - Text("How often CodexBar polls providers in the background.") + Text(L("refresh_cadence_subtitle")) .font(.footnote) .foregroundStyle(.tertiary) } @@ -81,21 +124,26 @@ struct GeneralPane: View { .frame(maxWidth: 200) } if self.settings.refreshFrequency == .manual { - Text("Auto-refresh is off; use the menu's Refresh command.") + Text(L("manual_refresh_hint")) .font(.footnote) .foregroundStyle(.secondary) } } PreferenceToggleRow( - title: "Check provider status", - subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " + - "Gemini/Antigravity, surfacing incidents in the icon and menu.", + title: L("check_provider_status_title"), + subtitle: L("check_provider_status_subtitle"), binding: self.$settings.statusChecksEnabled) PreferenceToggleRow( - title: "Session quota notifications", - subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + - "available again.", + title: L("session_quota_notifications_title"), + subtitle: L("session_quota_notifications_subtitle"), binding: self.$settings.sessionQuotaNotificationsEnabled) + PreferenceToggleRow( + title: L("quota_warning_notifications_title"), + subtitle: L("quota_warning_notifications_subtitle"), + binding: self.$settings.quotaWarningNotificationsEnabled) + if self.settings.quotaWarningNotificationsEnabled { + GlobalQuotaWarningSettingsView(settings: self.settings) + } } Divider() @@ -103,7 +151,7 @@ struct GeneralPane: View { SettingsSection(contentSpacing: 12) { HStack { Spacer() - Button("Quit CodexBar") { NSApp.terminate(nil) } + Button(L("quit_app")) { NSApp.terminate(nil) } .buttonStyle(.borderedProminent) .controlSize(.large) } @@ -119,7 +167,7 @@ struct GeneralPane: View { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName guard provider == .claude || provider == .codex else { - return Text("\(name): unsupported") + return Text(String(format: L("cost_status_unsupported"), name)) .font(.footnote) .foregroundStyle(.tertiary) } @@ -133,32 +181,33 @@ struct GeneralPane: View { formatter.unitsStyle = .abbreviated return formatter.string(from: seconds).map { " (\($0))" } ?? "" }() - return Text("\(name): fetching…\(elapsed)") + return Text(String(format: L("cost_status_fetching"), name, elapsed)) .font(.footnote) .foregroundStyle(.tertiary) } if let snapshot = self.store.tokenSnapshot(for: provider) { let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - return Text("\(name): \(updated) · 30d \(cost)") + return Text(String(format: L("cost_status_snapshot"), name, updated, cost)) .font(.footnote) .foregroundStyle(.tertiary) } if let error = self.store.tokenError(for: provider), !error.isEmpty { let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) - return Text("\(name): \(truncated)") + return Text(String(format: L("cost_status_error"), name, truncated)) .font(.footnote) .foregroundStyle(.tertiary) } if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { let rel = RelativeDateTimeFormatter() + rel.locale = Locale(identifier: "en_US") rel.unitsStyle = .abbreviated let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) - return Text("\(name): last attempt \(when)") + return Text(String(format: L("cost_status_last_attempt"), name, when)) .font(.footnote) .foregroundStyle(.tertiary) } - return Text("\(name): no data yet") + return Text(String(format: L("cost_status_no_data"), name)) .font(.footnote) .foregroundStyle(.tertiary) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 5ecff079d..e0e184158 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -11,7 +11,9 @@ struct ProviderDetailView: View { let settingsPickers: [ProviderSettingsPickerDescriptor] let settingsToggles: [ProviderSettingsToggleDescriptor] let settingsFields: [ProviderSettingsFieldDescriptor] + let settingsActions: [ProviderSettingsActionsDescriptor] let settingsTokenAccounts: ProviderSettingsTokenAccountsDescriptor? + let settingsOrganizations: ProviderSettingsOrganizationsDescriptor? let errorDisplay: ProviderErrorDisplay? @Binding var isErrorExpanded: Bool let onCopyError: (String) -> Void @@ -28,7 +30,9 @@ struct ProviderDetailView: View { settingsPickers: [ProviderSettingsPickerDescriptor], settingsToggles: [ProviderSettingsToggleDescriptor], settingsFields: [ProviderSettingsFieldDescriptor], + settingsActions: [ProviderSettingsActionsDescriptor] = [], settingsTokenAccounts: ProviderSettingsTokenAccountsDescriptor?, + settingsOrganizations: ProviderSettingsOrganizationsDescriptor? = nil, errorDisplay: ProviderErrorDisplay?, isErrorExpanded: Binding, onCopyError: @escaping (String) -> Void, @@ -44,7 +48,9 @@ struct ProviderDetailView: View { self.settingsPickers = settingsPickers self.settingsToggles = settingsToggles self.settingsFields = settingsFields + self.settingsActions = settingsActions self.settingsTokenAccounts = settingsTokenAccounts + self.settingsOrganizations = settingsOrganizations self.errorDisplay = errorDisplay self._isErrorExpanded = isErrorExpanded self.onCopyError = onCopyError @@ -63,7 +69,7 @@ struct ProviderDetailView: View { else { return nil } - guard provider == .openrouter else { + guard provider == .openrouter || provider == .mimo || provider == .moonshot else { return (label: "Plan", value: rawPlan) } @@ -99,7 +105,9 @@ struct ProviderDetailView: View { if let errorDisplay { ProviderErrorView( - title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:", + title: String( + format: L("last_fetch_failed_with_provider"), + self.store.metadata(for: self.provider).displayName), display: errorDisplay, isExpanded: self.$isErrorExpanded, onCopy: { self.onCopyError(errorDisplay.full) }) @@ -118,6 +126,12 @@ struct ProviderDetailView: View { ForEach(self.settingsFields) { field in ProviderSettingsFieldRowView(field: field) } + ForEach(self.settingsActions) { descriptor in + ProviderSettingsActionsRowView(descriptor: descriptor) + } + if let organizations = self.settingsOrganizations { + ProviderSettingsOrganizationsRowView(descriptor: organizations) + } } } @@ -125,6 +139,8 @@ struct ProviderDetailView: View { self.supplementarySettingsContent } + ProviderQuotaWarningSettingsView(provider: self.provider, settings: self.store.settings) + if !self.settingsToggles.isEmpty { ProviderSettingsSection(title: "Options") { ForEach(self.settingsToggles) { toggle in @@ -143,7 +159,9 @@ struct ProviderDetailView: View { private var hasSettings: Bool { !self.settingsPickers.isEmpty || !self.settingsFields.isEmpty || - self.settingsTokenAccounts != nil + !self.settingsActions.isEmpty || + self.settingsTokenAccounts != nil || + self.settingsOrganizations != nil } private var detailLabelWidth: CGFloat { @@ -154,6 +172,11 @@ struct ProviderDetailView: View { if !self.model.email.isEmpty { infoLabels.append("Account") } + if self.provider == .kiro, + self.model.metrics.isEmpty == false + { + infoLabels.append("Auth") + } if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) } @@ -296,6 +319,13 @@ private struct ProviderDetailInfoGrid: View { ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) } + if self.provider == .kiro, + let authMethod = self.store.snapshot(for: self.provider)?.loginMethod(for: .kiro), + !authMethod.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + ProviderDetailInfoRow(label: "Auth", value: authMethod, labelWidth: self.labelWidth) + } + if let planRow = ProviderDetailView.planRow( provider: self.provider, planText: self.model.planText) @@ -430,7 +460,8 @@ private struct ProviderMetricInlineRow: View { tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + paceOnTop: self.metric.paceOnTop, + warningMarkerPercents: self.metric.warningMarkerPercents) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -540,17 +571,21 @@ private struct ProviderMetricInlineCostRow: View { .frame(width: self.labelWidth, alignment: .leading) VStack(alignment: .leading, spacing: 4) { - UsageProgressBar( - percent: self.section.percentUsed, - tint: self.progressColor, - accessibilityLabel: "Usage used") - .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + if let percentUsed = self.section.percentUsed { + UsageProgressBar( + percent: percentUsed, + tint: self.progressColor, + accessibilityLabel: "Usage used") + .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) + } HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(String(format: "%.0f%% used", self.section.percentUsed)) - .font(.footnote) - .foregroundStyle(.secondary) - .monospacedDigit() + if let percentLine = self.section.percentLine { + Text(percentLine) + .font(.footnote) + .foregroundStyle(.secondary) + .monospacedDigit() + } Spacer(minLength: 8) Text(self.section.spendLine) .font(.footnote) diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 0fa246d88..4b5c96b78 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -36,7 +36,7 @@ struct ProviderErrorView: View { .fixedSize(horizontal: false, vertical: true) if self.display.preview != self.display.full { - Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() } + Button(self.isExpanded ? L("Hide details") : L("Show details")) { self.isExpanded.toggle() } .buttonStyle(.link) .font(.footnote) } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 514c7e3ce..c4f4cb817 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -207,16 +207,65 @@ struct ProviderSettingsFieldRowView: View { } } +@MainActor +struct ProviderSettingsActionsRowView: View { + let descriptor: ProviderSettingsActionsDescriptor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + + if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(self.descriptor.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + let actions = self.descriptor.actions.filter { $0.isVisible?() ?? true } + if !actions.isEmpty { + HStack(spacing: 10) { + ForEach(actions) { action in + Button(action.title) { + Task { @MainActor in + await action.perform() + } + } + .applyProviderSettingsButtonStyle(action.style) + .controlSize(.small) + } + } + } + } + } +} + @MainActor struct ProviderSettingsTokenAccountsRowView: View { let descriptor: ProviderSettingsTokenAccountsDescriptor @State private var newLabel: String = "" @State private var newToken: String = "" + @State private var newOrgID: String = "" var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) - .font(.subheadline.weight(.semibold)) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let title = self.descriptor.primaryAddActionTitle, + let action = self.descriptor.primaryAddAction + { + Button(title) { + Task { @MainActor in + await action() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.descriptor.subtitle) @@ -231,46 +280,76 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.footnote) .foregroundStyle(.secondary) } else { - let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) - Picker("", selection: Binding( - get: { selectedIndex }, - set: { index in self.descriptor.setActiveIndex(index) })) - { - ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in - Text(account.displayName).tag(index) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + HStack(alignment: .center, spacing: 10) { + Button { + self.descriptor.setActiveIndex(index) + } label: { + HStack(alignment: .center, spacing: 8) { + Image(systemName: self.isActive(index: index, accountCount: accounts.count) ? + "checkmark.circle.fill" : "circle") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.isActive(index: index, accountCount: accounts.count) ? + Color.accentColor : Color.secondary) + Text(account.displayName) + .font( + .footnote.weight( + self.isActive(index: index, accountCount: accounts.count) ? + .semibold : .regular)) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Button("Remove selected account") { - let account = accounts[selectedIndex] - self.descriptor.removeAccount(account.id) + Button("Remove") { + self.descriptor.removeAccount(account.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + if index < accounts.count - 1 { + Divider() + } + } } - .buttonStyle(.bordered) - .controlSize(.small) } - HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) - Button("Add") { - let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) - let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty, !token.isEmpty else { return } - self.descriptor.addAccount(label, token) - self.newLabel = "" - self.newToken = "" + if self.descriptor.primaryAddAction == nil { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + Button("Add") { + let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return } + let orgID = self.descriptor.showsOrganizationField + ? self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines) + : "" + self.descriptor.addAccount(label, token, orgID.isEmpty ? nil : orgID) + self.newLabel = "" + self.newToken = "" + self.newOrgID = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + if self.descriptor.showsOrganizationField { + TextField("Org ID (optional)", text: self.$newOrgID) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .help("Optional organization ID for accounts linked to multiple Anthropic organizations.") + } } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } HStack(spacing: 10) { @@ -287,6 +366,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } + + private func isActive(index: Int, accountCount: Int) -> Bool { + guard accountCount > 0 else { return false } + let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1)) + return selectedIndex == index + } } extension View { @@ -300,3 +385,79 @@ extension View { } } } + +@MainActor +struct ProviderSettingsOrganizationsRowView: View { + let descriptor: ProviderSettingsOrganizationsDescriptor + @State private var errorMessage: String? + @State private var isRefreshing = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + } + + if let subtitle = self.descriptor.subtitle, + !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + let entries = self.descriptor.entries() + if entries.allSatisfy(\.isLocked) { + Text("No organizations loaded. Click Refresh after setting your API key.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(entries) { entry in + Toggle(isOn: Binding( + get: { entry.isEnabled }, + set: { newValue in + self.descriptor.onToggle(entry.id, newValue) + })) { + VStack(alignment: .leading, spacing: 1) { + Text(entry.title) + .font(.footnote) + if let subtitle = entry.subtitle, + !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(.checkbox) + .disabled(entry.isLocked) + } + } + } + + HStack(spacing: 10) { + Button("Refresh organizations") { + Task { @MainActor in + self.isRefreshing = true + let result = await self.descriptor.onRefresh() + self.isRefreshing = false + self.errorMessage = result.success ? nil : result.errorMessage + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!self.descriptor.canRefresh() || self.isRefreshing) + if let errorMessage = self.errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } +} diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 1fcbcffa9..585690c08 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -53,7 +53,8 @@ extension ProvidersPane { lastAppActiveRunAtByID.removeValue(forKey: id) } }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) return impl.settingsPickers(context: context) .filter { $0.isVisible?() ?? true } } @@ -246,8 +247,11 @@ enum ProvidersPaneTestHarness { accounts: { [] }, activeIndex: { 0 }, setActiveIndex: { _ in }, - addAccount: { _, _ in }, + showsOrganizationField: false, + addAccount: { _, _, _ in }, removeAccount: { _ in }, + primaryAddActionTitle: nil, + primaryAddAction: nil, openConfigFile: {}, reloadFromDisk: {}) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index a5738086c..38d167188 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -9,6 +9,7 @@ struct ProvidersPane: View { let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator let codexAmbientLoginRunner: any CodexAmbientLoginRunning + let runProviderLoginFlow: @MainActor (UsageProvider) async -> Void @State private var expandedErrors: Set = [] @State private var settingsStatusTextByID: [String: String] = [:] @State private var settingsLastAppActiveRunAtByID: [String: Date] = [:] @@ -26,7 +27,8 @@ struct ProvidersPane: View { store: UsageStore, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, - codexAmbientLoginRunner: any CodexAmbientLoginRunning = DefaultCodexAmbientLoginRunner()) + codexAmbientLoginRunner: any CodexAmbientLoginRunning = DefaultCodexAmbientLoginRunner(), + runProviderLoginFlow: @escaping @MainActor (UsageProvider) async -> Void = { _ in }) { self.settings = settings self.store = store @@ -37,6 +39,7 @@ struct ProvidersPane: View { usageStore: store, managedAccountCoordinator: managedCodexAccountCoordinator) self.codexAmbientLoginRunner = codexAmbientLoginRunner + self.runProviderLoginFlow = runProviderLoginFlow } var body: some View { @@ -61,7 +64,9 @@ struct ProvidersPane: View { settingsPickers: self.extraSettingsPickers(for: provider), settingsToggles: self.extraSettingsToggles(for: provider), settingsFields: self.extraSettingsFields(for: provider), + settingsActions: self.extraSettingsActions(for: provider), settingsTokenAccounts: self.tokenAccountDescriptor(for: provider), + settingsOrganizations: self.extraSettingsOrganizations(for: provider), errorDisplay: self.providerErrorDisplay(provider), isErrorExpanded: self.expandedBinding(for: provider), onCopyError: { text in self.copyToPasteboard(text) }, @@ -99,7 +104,7 @@ struct ProvidersPane: View { } }) } else { - Text("Select a provider") + Text(L("select_a_provider")) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } @@ -127,7 +132,7 @@ struct ProvidersPane: View { active.onConfirm() self.activeConfirmation = nil } - Button("Cancel", role: .cancel) { self.activeConfirmation = nil } + Button(L("cancel"), role: .cancel) { self.activeConfirmation = nil } } }, message: { @@ -176,9 +181,9 @@ struct ProvidersPane: View { let relative = snapshot.updatedAt.relativeDescription() usageText = relative } else if self.store.isStale(provider: provider) { - usageText = "last fetch failed" + usageText = L("last_fetch_failed") } else { - usageText = "usage not fetched yet" + usageText = L("usage_not_fetched_yet") } let presentationContext = ProviderPresentationContext( @@ -199,8 +204,7 @@ struct ProvidersPane: View { let projection = self.settings.codexVisibleAccountProjection let degradedNotice: CodexAccountsSectionNotice? = if projection.hasUnreadableAddedAccountStore { CodexAccountsSectionNotice( - text: "Managed account storage is unreadable. Live account access is still available, " - + "but managed add, re-auth, and remove actions are disabled until the store is recoverable.", + text: L("managed_account_storage_unreadable"), tone: .warning) } else { nil @@ -303,9 +307,9 @@ struct ProvidersPane: View { func requestManagedCodexAccountRemoval(_ account: CodexVisibleAccount) { guard let accountID = account.storedAccountID else { return } self.activeConfirmation = ProviderSettingsConfirmationState( - title: "Remove Codex account?", - message: "Remove \(account.email) from CodexBar? Its managed Codex home will be deleted.", - confirmTitle: "Remove", + title: L("remove_codex_account_title"), + message: String(format: L("remove_account_message"), account.email), + confirmTitle: L("remove"), onConfirm: { Task { @MainActor in await self.removeManagedCodexAccount(id: accountID) @@ -346,6 +350,21 @@ struct ProvidersPane: View { .filter { $0.isVisible?() ?? true } } + private func extraSettingsActions(for provider: UsageProvider) -> [ProviderSettingsActionsDescriptor] { + guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } + let context = self.makeSettingsContext(provider: provider) + return impl.settingsActions(context: context) + .filter { $0.isVisible?() ?? true } + } + + private func extraSettingsOrganizations( + for provider: UsageProvider) -> ProviderSettingsOrganizationsDescriptor? + { + guard let impl = ProviderCatalog.implementation(for: provider) else { return nil } + let context = self.makeSettingsContext(provider: provider) + return impl.settingsOrganizations(context: context) + } + func tokenAccountDescriptor(for provider: UsageProvider) -> ProviderSettingsTokenAccountsDescriptor? { guard let support = TokenAccountSupportCatalog.support(for: provider) else { return nil } let context = self.makeSettingsContext(provider: provider) @@ -374,8 +393,13 @@ struct ProvidersPane: View { } } }, - addAccount: { label, token in - self.settings.addTokenAccount(provider: provider, label: label, token: token) + showsOrganizationField: provider == .claude, + addAccount: { label, token, organizationID in + self.settings.addTokenAccount( + provider: provider, + label: label, + token: token, + organizationID: organizationID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) @@ -390,6 +414,13 @@ struct ProvidersPane: View { } } }, + primaryAddActionTitle: provider == .copilot ? "Add Account" : nil, + primaryAddAction: provider == .copilot ? { + await CopilotLoginFlow.run(settings: self.settings) + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } : nil, openConfigFile: { self.settings.openTokenAccountsFile() }, @@ -440,6 +471,9 @@ struct ProvidersPane: View { }, requestConfirmation: { confirmation in self.activeConfirmation = ProviderSettingsConfirmationState(confirmation: confirmation) + }, + runLoginFlow: { + await self.runProviderLoginFlow(provider) }) } @@ -447,18 +481,22 @@ struct ProvidersPane: View { let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (API key limit)"), + title: L("primary_api_key_limit")), + ] + } else if SettingsStore.isBalanceOnlyProvider(provider) { + options = [ + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), ] } else if provider == .abacus { let metadata = self.store.metadata(for: provider) options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: String(format: L("metric_primary"), metadata.sessionLabel)), ] } else { let metadata = self.store.metadata(for: provider) @@ -467,19 +505,19 @@ struct ProvidersPane: View { let supportsTertiary = self.settings.menuBarMetricSupportsTertiary(for: provider, snapshot: snapshot) let supportsExtraUsage = self.settings.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot) var metricOptions: [ProviderSettingsPickerOption] = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("automatic")), ProviderSettingsPickerOption( id: MenuBarMetricPreference.primary.rawValue, - title: "Primary (\(metadata.sessionLabel))"), + title: String(format: L("metric_primary"), metadata.sessionLabel)), ProviderSettingsPickerOption( id: MenuBarMetricPreference.secondary.rawValue, - title: "Secondary (\(metadata.weeklyLabel))"), + title: String(format: L("metric_secondary"), metadata.weeklyLabel)), ] if supportsTertiary { let tertiaryTitle = metadata.opusLabel ?? MenuBarMetricPreference.tertiary.label metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.tertiary.rawValue, - title: "Tertiary (\(tertiaryTitle))")) + title: String(format: L("metric_tertiary"), tertiaryTitle))) } if supportsExtraUsage { metricOptions.append(ProviderSettingsPickerOption( @@ -489,14 +527,14 @@ struct ProvidersPane: View { if supportsAverage { metricOptions.append(ProviderSettingsPickerOption( id: MenuBarMetricPreference.average.rawValue, - title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))")) + title: String(format: L("metric_average"), metadata.sessionLabel, metadata.weeklyLabel))) } options = metricOptions } return ProviderSettingsPickerDescriptor( id: "menuBarMetric", - title: "Menu bar metric", - subtitle: "Choose which window drives the menu bar percent.", + title: L("menu_bar_metric_title"), + subtitle: Self.menuBarMetricPickerSubtitle(for: provider), binding: Binding( get: { self.settings @@ -512,6 +550,21 @@ struct ProvidersPane: View { onChange: nil) } + private static func menuBarMetricPickerSubtitle(for provider: UsageProvider) -> String { + switch provider { + case .deepseek: + L("menu_bar_metric_subtitle_deepseek") + case .moonshot: + L("menu_bar_metric_subtitle_moonshot") + case .mistral: + L("menu_bar_metric_subtitle_mistral") + case .kimik2: + L("menu_bar_metric_subtitle_kimik2") + default: + L("menu_bar_metric_subtitle") + } + } + func menuCardModel(for provider: UsageProvider) -> UsageMenuCardView.Model { let metadata = self.store.metadata(for: provider) let snapshot = self.store.snapshot(for: provider) @@ -579,11 +632,22 @@ struct ProvidersPane: View { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, + quotaWarningThresholds: [ + .session: self.quotaWarningMarkerThresholds(provider: provider, window: .session), + .weekly: self.quotaWarningMarkerThresholds(provider: provider, window: .weekly), + ], now: now) return UsageMenuCardView.Model.make(input) } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + guard self.settings.quotaWarningMarkersVisible else { return [] } + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } + return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + } + private func refreshCodexProvider() async { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshCodexAccountScopedState(allowDisabled: true) @@ -599,20 +663,20 @@ struct ProvidersPane: View { error == .authenticationInProgress { return CodexAccountsSectionNotice( - text: "A managed Codex login is already running. Wait for it to finish before adding " - + "or re-authenticating another account.", + text: L("managed_login_already_running"), tone: .warning) } if let error = error as? ManagedCodexAccountServiceError { let message = switch error { case .loginFailed: - "Managed Codex login did not complete. Try again after finishing the browser login flow." + 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." + L("managed_login_missing_email") + case .workspaceSelectionCancelled: + L("workspace_selection_cancelled") case let .unsafeManagedHome(path): - "CodexBar refused to modify an unexpected managed home path: \(path)" + String(format: L("unsafe_managed_home"), path) } return CodexAccountsSectionNotice(text: message, tone: .warning) } diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 408a83d6d..961b1b9b8 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -1,4 +1,5 @@ import AppKit +import CodexBarCore import SwiftUI enum PreferencesTab: String, CaseIterable, Hashable { @@ -9,18 +10,18 @@ enum PreferencesTab: String, CaseIterable, Hashable { case about case debug - static let defaultWidth: CGFloat = 496 - static let providersWidth: CGFloat = 720 - static let windowHeight: CGFloat = 580 + static let defaultWidth: CGFloat = 546 + static let providersWidth: CGFloat = 792 + static let windowHeight: CGFloat = 638 var title: String { switch self { - case .general: "General" - case .providers: "Providers" - case .display: "Display" - case .advanced: "Advanced" - case .about: "About" - case .debug: "Debug" + case .general: L("tab_general") + case .providers: L("tab_providers") + case .display: L("tab_display") + case .advanced: L("tab_advanced") + case .about: L("tab_about") + case .debug: L("tab_debug") } } @@ -41,6 +42,7 @@ struct PreferencesView: View { @Bindable var selection: PreferencesSelection let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator + let runProviderLoginFlow: @MainActor (UsageProvider) async -> Void @State private var contentWidth: CGFloat = PreferencesTab.general.preferredWidth @State private var contentHeight: CGFloat = PreferencesTab.general.preferredHeight @@ -50,7 +52,8 @@ struct PreferencesView: View { updater: UpdaterProviding, selection: PreferencesSelection, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), - codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil) + codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? = nil, + runProviderLoginFlow: @escaping @MainActor (UsageProvider) async -> Void = { _ in }) { self.settings = settings self.store = store @@ -62,40 +65,43 @@ struct PreferencesView: View { settingsStore: settings, usageStore: store, managedAccountCoordinator: managedCodexAccountCoordinator) + self.runProviderLoginFlow = runProviderLoginFlow } var body: some View { TabView(selection: self.$selection.tab) { GeneralPane(settings: self.settings, store: self.store) - .tabItem { Label("General", systemImage: "gearshape") } + .tabItem { Label(L("tab_general"), systemImage: "gearshape") } .tag(PreferencesTab.general) ProvidersPane( settings: self.settings, store: self.store, managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator) - .tabItem { Label("Providers", systemImage: "square.grid.2x2") } + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + runProviderLoginFlow: self.runProviderLoginFlow) + .tabItem { Label(L("tab_providers"), systemImage: "square.grid.2x2") } .tag(PreferencesTab.providers) DisplayPane(settings: self.settings, store: self.store) - .tabItem { Label("Display", systemImage: "eye") } + .tabItem { Label(L("tab_display"), systemImage: "eye") } .tag(PreferencesTab.display) AdvancedPane(settings: self.settings) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + .tabItem { Label(L("tab_advanced"), systemImage: "slider.horizontal.3") } .tag(PreferencesTab.advanced) AboutPane(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } + .tabItem { Label(L("tab_about"), systemImage: "info.circle") } .tag(PreferencesTab.about) if self.settings.debugMenuEnabled { DebugPane(settings: self.settings, store: self.store) - .tabItem { Label("Debug", systemImage: "ladybug") } + .tabItem { Label(L("tab_debug"), systemImage: "ladybug") } .tag(PreferencesTab.debug) } } + .id(self.settings.appLanguage) .padding(.horizontal, 24) .padding(.vertical, 16) .frame(width: self.contentWidth, height: self.contentHeight) diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index d07477e69..cec160ad9 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -18,8 +18,10 @@ enum ProviderBrandIcon { static func image(for provider: UsageProvider) -> NSImage? { let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName - guard let bundle = self.resourceBundle, - let url = bundle.url(forResource: baseName, withExtension: "svg"), + guard let bundle = self.resourceBundle else { + return nil + } + guard let url = bundle.url(forResource: baseName, withExtension: "svg"), let image = NSImage(contentsOf: url) else { return nil diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 764e418c4..ebb5f473d 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -37,6 +37,10 @@ struct ProviderRegistry { isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) }, descriptor: descriptor, makeFetchContext: { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: provider, + settings: settings, + override: nil) let sourceMode = ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) ?? .auto @@ -52,6 +56,7 @@ struct ProviderRegistry { runtime: .app, sourceMode: sourceMode, includeCredits: false, + includeOptionalUsage: settings.showOptionalCreditsAndExtraUsage, webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, @@ -59,7 +64,16 @@ struct ProviderRegistry { settings: snapshot, fetcher: fetcher, claudeFetcher: claudeFetcher, - browserDetection: browserDetection) + browserDetection: browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: { provider, accountID, token in + await MainActor.run { + settings.updateTokenAccount( + provider: provider, + accountID: accountID, + token: token) + } + }) }) specs[provider] = spec } @@ -70,13 +84,17 @@ struct ProviderRegistry { @MainActor static func makeSettingsSnapshot( settings: SettingsStore, - tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + tokenOverride: TokenAccountOverride?, + codexActiveSourceOverride: CodexActiveSource? = nil) -> ProviderSettingsSnapshot { settings.ensureTokenAccountsLoaded() var builder = ProviderSettingsSnapshotBuilder( debugMenuEnabled: settings.debugMenuEnabled, debugKeepCLISessionsAlive: settings.debugKeepCLISessionsAlive) - let context = ProviderSettingsSnapshotContext(settings: settings, tokenOverride: tokenOverride) + let context = ProviderSettingsSnapshotContext( + settings: settings, + tokenOverride: tokenOverride, + codexActiveSourceOverride: codexActiveSourceOverride) for implementation in ProviderCatalog.all { if let contribution = implementation.settingsSnapshot(context: context) { builder.apply(contribution) @@ -90,23 +108,30 @@ struct ProviderRegistry { base: [String: String], provider: UsageProvider, settings: SettingsStore, - tokenOverride: TokenAccountOverride?) -> [String: String] + tokenOverride: TokenAccountOverride?, + codexActiveSourceOverride: CodexActiveSource? = nil) -> [String: String] { let account = ProviderTokenAccountSelection.selectedAccount( provider: provider, settings: settings, override: tokenOverride) - var env = ProviderConfigEnvironment.applyAPIKeyOverride( + var env = ProviderConfigEnvironment.applyProviderConfigOverrides( base: base, provider: provider, config: settings.providerConfig(for: provider)) // If token account is selected, use its token instead of config's apiKey - if let account, let override = TokenAccountSupportCatalog.envOverride( - for: provider, - token: account.token) - { - for (key, value) in override { - env[key] = value + if let account { + TokenAccountSupportCatalog.scrubEnvironmentForSelectedAccount( + &env, + provider: provider, + token: account.token) + if let override = TokenAccountSupportCatalog.envOverride( + for: provider, + token: account.token) + { + for (key, value) in override { + env[key] = value + } } } // Managed Codex routing only scopes remote account fetches such as identity, plan, @@ -116,11 +141,13 @@ struct ProviderRegistry { // Mac's Codex sessions, not as account-owned remote state. If we later want // account-scoped token history in the UI, that needs an explicit product decision and // presentation change so the two concepts are not conflated. - if provider == .codex, - case .managedAccount = settings.codexActiveSource, - let managedHomePath = settings.activeManagedCodexRemoteHomePath - { - env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) + if provider == .codex { + let codexActiveSource = codexActiveSourceOverride ?? settings.codexResolvedActiveSource + if case .managedAccount = codexActiveSource, + let managedHomePath = settings.managedCodexRemoteHomePath(forActiveSource: codexActiveSource) + { + env = CodexHomeScope.scopedEnvironment(base: env, codexHome: managedHomePath) + } } return env } diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift index 05ce53c53..1434b10df 100644 --- a/Sources/CodexBar/ProviderSwitcherButtons.swift +++ b/Sources/CodexBar/ProviderSwitcherButtons.swift @@ -34,7 +34,7 @@ final class InlineIconToggleButton: NSButton { self.paddingConstraints.first { $0.firstAttribute == .top }?.constant = self.contentPadding.top self.paddingConstraints.first { $0.firstAttribute == .leading }?.constant = self.contentPadding.left self.paddingConstraints.first { $0.firstAttribute == .trailing }?.constant = -self.contentPadding.right - self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -(self.contentPadding.bottom + 4) + self.paddingConstraints.first { $0.firstAttribute == .bottom }?.constant = -self.contentPadding.bottom if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } @@ -47,6 +47,7 @@ final class InlineIconToggleButton: NSButton { super.attributedTitle = NSAttributedString(string: "") super.attributedAlternateTitle = NSAttributedString(string: "") self.titleField.stringValue = newValue + self.setAccessibilityLabel(newValue) if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } @@ -108,6 +109,7 @@ final class InlineIconToggleButton: NSButton { self.setButtonType(.toggle) self.controlSize = .small self.wantsLayer = true + self.setAccessibilityRole(.button) self.iconView.imageScaling = .scaleNone self.iconView.translatesAutoresizingMaskIntoConstraints = false @@ -143,7 +145,7 @@ final class InlineIconToggleButton: NSButton { centerX.priority = .defaultHigh let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, - constant: -(self.contentPadding.bottom + 4)) + constant: -self.contentPadding.bottom) self.paddingConstraints = [top, leading, trailing, bottom, centerX] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) @@ -176,6 +178,7 @@ final class StackedToggleButton: NSButton { super.attributedTitle = NSAttributedString(string: "") super.attributedAlternateTitle = NSAttributedString(string: "") self.titleField.stringValue = newValue + self.setAccessibilityLabel(newValue) if !self.isConfiguring { self.invalidateIntrinsicContentSize() } } } @@ -237,6 +240,7 @@ final class StackedToggleButton: NSButton { self.setButtonType(.toggle) self.controlSize = .small self.wantsLayer = true + self.setAccessibilityRole(.button) self.iconView.imageScaling = .scaleNone self.iconView.translatesAutoresizingMaskIntoConstraints = false @@ -261,7 +265,6 @@ final class StackedToggleButton: NSButton { // Avoid subpixel centering: pin from the top so the icon sits on whole-point coordinates. // Force an even layout width (button width minus padding) so the icon doesn't land on 0.5pt centers. - // Reserve some bottom space for the "weekly remaining" indicator line. let top = self.stack.topAnchor.constraint( equalTo: self.topAnchor, constant: self.contentPadding.top) @@ -273,7 +276,7 @@ final class StackedToggleButton: NSButton { constant: -self.contentPadding.right) let bottom = self.stack.bottomAnchor.constraint( lessThanOrEqualTo: self.bottomAnchor, - constant: -(self.contentPadding.bottom + 4)) + constant: -self.contentPadding.bottom) self.paddingConstraints = [top, leading, trailing, bottom] NSLayoutConstraint.activate(self.paddingConstraints + self.iconSizeConstraints) diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanSettingsStore.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanSettingsStore.swift index 4b2b39969..61e12ce4e 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanSettingsStore.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanSettingsStore.swift @@ -61,7 +61,9 @@ extension SettingsStore { guard self.userDefaults.bool(forKey: Self.alibabaAutoEnableAppliedKey) == false else { return } let hasConfigToken = self.configSnapshot.providerConfig(for: .alibaba)?.sanitizedAPIKey != nil - let hasEnvironmentToken = AlibabaCodingPlanSettingsReader.apiToken(environment: environment) != nil + let shouldUseEnvironmentToken = !Self.isRunningTests || self.userDefaults === UserDefaults.standard + let hasEnvironmentToken = shouldUseEnvironmentToken && + AlibabaCodingPlanSettingsReader.apiToken(environment: environment) != nil guard hasConfigToken || hasEnvironmentToken else { return } if let metadata = ProviderDescriptorRegistry.metadata[.alibaba], diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift index e41aa8fe6..b42790e3e 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift @@ -3,9 +3,31 @@ import CodexBarCore @MainActor extension StatusItemController { func runAntigravityLoginFlow() async { + let store = self.store + let phaseHandler: @Sendable (AntigravityLoginRunner.Phase) -> Void = { [weak self] phase in + Task { @MainActor in + switch phase { + case .waitingBrowser: + self?.loginPhase = .waitingBrowser + } + } + } + let result = await AntigravityLoginRunner.run(onPhaseChange: phaseHandler) { + Task { @MainActor in + if let credentials = try? AntigravityOAuthCredentialsStore().load() { + self.store.settings.upsertAntigravityOAuthAccount(credentials) + } + await store.refresh() + CodexBarLog.logger(LogCategories.login).info("Auto-refreshed after Antigravity auth") + } + } + guard !Task.isCancelled else { return } self.loginPhase = .idle - self.presentLoginAlert( - title: "Antigravity login is managed in the app", - message: "Open Antigravity to sign in, then refresh CodexBar.") + self.presentAntigravityLoginResult(result) + let outcome = self.describe(result.outcome) + self.loginLogger.info("Antigravity login", metadata: ["outcome": outcome]) + if case .success = result.outcome { + self.postLoginNotification(for: .antigravity) + } } } diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift new file mode 100644 index 000000000..0ac446c87 --- /dev/null +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift @@ -0,0 +1,484 @@ +import AppKit +import CodexBarCore +import Darwin +import Foundation +import Network + +enum AntigravityLoginRunner { + enum Phase { + case waitingBrowser + } + + struct Result { + enum Outcome { + case success(String?) + case cancelled + case timedOut + case launchFailed(String) + case failed(String) + } + + let outcome: Outcome + } + + static func run( + timeout: TimeInterval = 120, + onPhaseChange: (@Sendable (Phase) -> Void)? = nil, + onCredentialsCreated: (@Sendable () -> Void)? = nil) async -> Result + { + guard let oauthClient = AntigravityOAuthConfig.resolvedClient() else { + return Result(outcome: .failed(AntigravityOAuthConfig.missingCredentialsMessage)) + } + + let state = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let server = AntigravityLoopbackServer(state: state) + + do { + let callbackURL = try await server.start() + let authURL = try Self.makeAuthorizationURL( + redirectURL: callbackURL, + state: state, + oauthClient: oauthClient) + onPhaseChange?(.waitingBrowser) + + let opened = await MainActor.run { + NSWorkspace.shared.open(authURL) + } + guard opened else { + server.stop() + return Result(outcome: .launchFailed(authURL.absoluteString)) + } + + let callback = try await withThrowingTaskGroup(of: AntigravityOAuthCallback.self) { group in + group.addTask { + try await server.waitForCallback() + } + group.addTask { + try await Task.sleep(for: .seconds(timeout)) + server.cancelCallbackWait(with: AntigravityLoginError.timedOut) + throw AntigravityLoginError.timedOut + } + defer { group.cancelAll() } + return try await group.next().unsafelyUnwrapped + } + server.stop() + + if let error = callback.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty { + if error == "access_denied" { + return Result(outcome: .cancelled) + } + return Result(outcome: .failed(error)) + } + + guard callback.returnedState == state else { + return Result(outcome: .failed("Google login state mismatch.")) + } + guard let code = callback.code?.trimmingCharacters(in: .whitespacesAndNewlines), !code.isEmpty else { + return Result(outcome: .failed("Google login did not return an authorization code.")) + } + + let tokenResponse = try await Self.exchangeCodeForTokens( + code: code, + redirectURL: callbackURL, + oauthClient: oauthClient) + let email = try await Self.fetchUserEmail(accessToken: tokenResponse.accessToken) + let credentials = AntigravityOAuthCredentials( + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiryDate: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn)), + idToken: tokenResponse.idToken, + email: email, + projectID: nil, + clientID: oauthClient.clientID, + clientSecret: oauthClient.clientSecret) + try AntigravityOAuthCredentialsStore().save(credentials) + onCredentialsCreated?() + return Result(outcome: .success(email)) + } catch is CancellationError { + server.stop() + return Result(outcome: .cancelled) + } catch AntigravityLoginError.timedOut { + server.stop() + return Result(outcome: .timedOut) + } catch let AntigravityLoginError.launchFailed(message) { + server.stop() + return Result(outcome: .launchFailed(message)) + } catch { + server.stop() + return Result(outcome: .failed(error.localizedDescription)) + } + } + + static func makeAuthorizationURL( + redirectURL: URL, + state: String, + oauthClient: AntigravityOAuthClient) throws -> URL + { + guard var components = URLComponents(url: AntigravityOAuthConfig.authURL, resolvingAgainstBaseURL: false) else { + throw AntigravityLoginError.invalidAuthorizationURL + } + components.queryItems = [ + URLQueryItem(name: "client_id", value: oauthClient.clientID), + URLQueryItem(name: "redirect_uri", value: redirectURL.absoluteString), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: AntigravityOAuthConfig.scopes.joined(separator: " ")), + URLQueryItem(name: "access_type", value: "offline"), + URLQueryItem(name: "prompt", value: "select_account consent"), + URLQueryItem(name: "state", value: state), + ] + guard let url = components.url else { + throw AntigravityLoginError.invalidAuthorizationURL + } + return url + } + + private static func exchangeCodeForTokens( + code: String, + redirectURL: URL, + oauthClient: AntigravityOAuthClient) async throws -> TokenResponse + { + var request = URLRequest(url: AntigravityOAuthConfig.tokenURL) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = Self.formBody([ + "code": code, + "client_id": oauthClient.clientID, + "client_secret": oauthClient.clientSecret, + "redirect_uri": redirectURL.absoluteString, + "grant_type": "authorization_code", + ]) + + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AntigravityLoginError.failed("Invalid token response.") + } + guard httpResponse.statusCode == 200 else { + let message = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "HTTP \(httpResponse.statusCode)" + throw AntigravityLoginError.failed(message) + } + do { + return try JSONDecoder().decode(TokenResponse.self, from: data) + } catch { + throw AntigravityLoginError.failed("Could not decode token response.") + } + } + + private static func fetchUserEmail(accessToken: String) async throws -> String? { + var request = URLRequest(url: AntigravityOAuthConfig.userInfoURL) + request.timeoutInterval = 15 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + + do { + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + return nil + } + let userInfo = try JSONDecoder().decode(UserInfoResponse.self, from: data) + let email = userInfo.email?.trimmingCharacters(in: .whitespacesAndNewlines) + return (email?.isEmpty == false) ? email : nil + } catch { + return nil + } + } + + private static func formBody(_ values: [String: String]) -> Data? { + values + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") + .data(using: .utf8) + } +} + +private enum AntigravityLoginError: LocalizedError { + case invalidAuthorizationURL + case timedOut + case launchFailed(String) + case failed(String) + + var errorDescription: String? { + switch self { + case .invalidAuthorizationURL: + "Could not build the Antigravity login URL." + case .timedOut: + "Antigravity login timed out." + case let .launchFailed(message): + message + case let .failed(message): + message + } + } +} + +private struct TokenResponse: Decodable { + let accessToken: String + let refreshToken: String? + let expiresIn: Int + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case idToken = "id_token" + } +} + +private struct UserInfoResponse: Decodable { + let email: String? +} + +private struct AntigravityOAuthCallback { + let code: String? + let returnedState: String? + let error: String? +} + +private final class AntigravityLoopbackServer: @unchecked Sendable { + private let expectedState: String + private let queue = DispatchQueue(label: "codexbar.antigravity.oauth") + private let lock = NSLock() + private var listener: NWListener? + private var readyContinuation: CheckedContinuation? + private var callbackContinuation: CheckedContinuation? + private var pendingCallbackResult: Result? + private var completed = false + + init(state: String) { + self.expectedState = state + } + + func start() async throws -> URL { + let port = try Self.findAvailablePort() + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + throw AntigravityLoginError.failed("Could not reserve a local callback port.") + } + let listener = try NWListener(using: .tcp, on: endpointPort) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + + return try await withCheckedThrowingContinuation { continuation in + self.readyContinuation = continuation + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + let url = URL(string: "http://127.0.0.1:\(port)/callback")! + self.finishReady(with: .success(url)) + case let .failed(error): + self.finishReady(with: .failure(error)) + self.finishCallback(with: .failure(error)) + default: + break + } + } + listener.start(queue: self.queue) + } + } + + func waitForCallback() async throws -> AntigravityOAuthCallback { + try await withCheckedThrowingContinuation { continuation in + self.lock.lock() + defer { self.lock.unlock() } + if let pending = self.pendingCallbackResult { + self.pendingCallbackResult = nil + switch pending { + case let .success(callback): + continuation.resume(returning: callback) + case let .failure(error): + continuation.resume(throwing: error) + } + return + } + self.callbackContinuation = continuation + } + } + + func stop() { + self.listener?.cancel() + self.listener = nil + } + + func cancelCallbackWait(with error: Error) { + self.stop() + self.finishCallback(with: .failure(error)) + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: self.queue) + self.receive(on: connection, accumulated: Data()) + } + + private func receive(on connection: NWConnection, accumulated: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + guard let self else { return } + if let error { + self.finishCallback(with: .failure(error)) + connection.cancel() + return + } + + var buffer = accumulated + if let data { + buffer.append(data) + } + + let headerMarker = Data("\r\n\r\n".utf8) + if buffer.range(of: headerMarker) == nil, !isComplete { + self.receive(on: connection, accumulated: buffer) + return + } + + let callback = self.parseCallback(from: buffer) + let response = self.httpResponse(for: callback) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + self.finishCallback(with: .success(callback)) + } + } + + private func parseCallback(from data: Data) -> AntigravityOAuthCallback { + guard let request = String(data: data, encoding: .utf8), + let line = request.components(separatedBy: "\r\n").first + else { + return AntigravityOAuthCallback(code: nil, returnedState: nil, error: "Invalid callback request.") + } + + let parts = line.split(separator: " ") + guard parts.count >= 2, + let url = URL(string: "http://127.0.0.1\(parts[1])"), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return AntigravityOAuthCallback(code: nil, returnedState: nil, error: "Invalid callback URL.") + } + + let code = components.queryItems?.first(where: { $0.name == "code" })?.value + let returnedState = components.queryItems?.first(where: { $0.name == "state" })?.value + let error = components.queryItems?.first(where: { $0.name == "error" })?.value + + guard components.path == "/callback" else { + return AntigravityOAuthCallback(code: nil, returnedState: returnedState, error: "Unexpected callback path.") + } + if let returnedState, returnedState != self.expectedState { + return AntigravityOAuthCallback(code: code, returnedState: returnedState, error: "State mismatch.") + } + return AntigravityOAuthCallback(code: code, returnedState: returnedState, error: error) + } + + private func httpResponse(for callback: AntigravityOAuthCallback) -> Data { + let success = callback.error == nil && callback.code?.isEmpty == false + let status = success ? "200 OK" : "400 Bad Request" + let title = success ? "Login Successful" : "Login Failed" + let detail = success + ? "You can close this window and return to CodexBar." + : "You can close this window and try again." + let html = """ + + +

\(title)

+

\(detail)

+ + + """ + let body = Data(html.utf8) + let header = """ + HTTP/1.1 \(status)\r + Content-Type: text/html; charset=utf-8\r + Content-Length: \(body.count)\r + Connection: close\r + \r + """ + var response = Data(header.utf8) + response.append(body) + return response + } + + private func finishReady(with result: Result) { + self.lock.lock() + let continuation = self.readyContinuation + self.readyContinuation = nil + self.lock.unlock() + switch result { + case let .success(url): + continuation?.resume(returning: url) + case let .failure(error): + continuation?.resume(throwing: error) + } + } + + private func finishCallback(with result: Result) { + self.lock.lock() + guard !self.completed else { + self.lock.unlock() + return + } + self.completed = true + let continuation = self.callbackContinuation + self.callbackContinuation = nil + if continuation == nil { + self.pendingCallbackResult = result + } + self.lock.unlock() + guard let continuation else { return } + switch result { + case let .success(callback): + continuation.resume(returning: callback) + case let .failure(error): + continuation.resume(throwing: error) + } + } + + private static func findAvailablePort() throws -> UInt16 { + let socketFD = socket(AF_INET, Int32(SOCK_STREAM), 0) + guard socketFD >= 0 else { + throw AntigravityLoginError.failed("Could not create a local callback socket.") + } + defer { close(socketFD) } + + var value: Int32 = 1 + setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(0).bigEndian + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(socketFD, sockaddrPointer, socklen_t(MemoryLayout.stride)) + } + } + guard bindResult == 0 else { + throw AntigravityLoginError.failed("Could not bind a local callback port.") + } + + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.stride) + let nameResult = withUnsafeMutablePointer(to: &boundAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + getsockname(socketFD, sockaddrPointer, &length) + } + } + guard nameResult == 0 else { + throw AntigravityLoginError.failed("Could not inspect the callback port.") + } + return UInt16(bigEndian: boundAddress.sin_port) + } +} + +extension CharacterSet { + fileprivate static let urlQueryValueAllowed: CharacterSet = { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "+&=") + return allowed + }() +} diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift index fcac10ed1..bb3b01070 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift @@ -1,10 +1,87 @@ import CodexBarCore import CodexBarMacroSupport import Foundation +import SwiftUI @ProviderImplementationRegistration struct AntigravityProviderImplementation: ProviderImplementation { let id: UsageProvider = .antigravity + let supportsLoginFlow: Bool = true + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.antigravityUsageDataSource + _ = settings.tokenAccountsData(for: .antigravity) + } + + @MainActor + func defaultSourceLabel(context: ProviderSourceLabelContext) -> String? { + context.settings.antigravityUsageDataSource.rawValue + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.antigravityUsageDataSource { + case .auto: .auto + case .oauth: .oauth + case .cli: .cli + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let usageBinding = Binding( + get: { context.settings.antigravityUsageDataSource.rawValue }, + set: { raw in + context.settings.antigravityUsageDataSource = AntigravityUsageDataSource(rawValue: raw) ?? .auto + }) + let usageOptions = AntigravityUsageDataSource.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + return [ + ProviderSettingsPickerDescriptor( + id: "antigravity-usage-source", + title: "Usage source", + subtitle: "Auto uses the local IDE API first, then Google OAuth when the IDE is closed.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.antigravityUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .antigravity) + return label == "auto" ? nil : label + }), + ] + } + + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + let accountCount = context.settings.tokenAccounts(for: .antigravity).count + let loginTitle = accountCount > 0 ? "Add Google Account" : "Login with Google" + let subtitle = """ + 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. + """ + return [ + ProviderSettingsActionsDescriptor( + id: "antigravity-oauth", + title: "Google OAuth", + subtitle: subtitle, + actions: [ + ProviderSettingsActionDescriptor( + id: "antigravity-oauth-login", + title: loginTitle, + style: .bordered, + isVisible: nil, + perform: { + await context.runLoginFlow() + }), + ], + isVisible: nil), + ] + } func detectVersion(context _: ProviderVersionContext) async -> String? { await AntigravityStatusProbe.detectVersion() @@ -13,6 +90,11 @@ struct AntigravityProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { + ("Add Account...", .switchAccount(.antigravity)) + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runAntigravityLoginFlow() diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift new file mode 100644 index 000000000..f32a9443d --- /dev/null +++ b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var antigravityUsageDataSource: AntigravityUsageDataSource { + get { + let source = self.configSnapshot.providerConfig(for: .antigravity)?.source + return Self.antigravityUsageDataSource(from: source) + } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .oauth: .oauth + case .cli: .cli + } + self.updateProviderConfig(provider: .antigravity) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .antigravity, field: "usageSource", value: newValue.rawValue) + } + } + + func upsertAntigravityOAuthAccount(_ credentials: AntigravityOAuthCredentials) { + guard let token = try? AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) else { return } + let trimmedEmail = credentials.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = (trimmedEmail?.isEmpty == false) ? trimmedEmail : nil + let data = self.tokenAccountsData(for: .antigravity) + let label = email ?? "Google Account \((data?.accounts.count ?? 0) + 1)" + if let email, + let data, + let index = data.accounts.firstIndex(where: { account in + account.externalIdentifier == email + }) + { + let account = data.accounts[index] + self.updateTokenAccount( + provider: .antigravity, + accountID: account.id, + label: label, + token: token, + externalIdentifier: .some(email)) + self.setActiveTokenAccountIndex(index, for: .antigravity) + } else { + self.addTokenAccount( + provider: .antigravity, + label: label, + token: token, + externalIdentifier: email) + } + } +} + +extension SettingsStore { + private static func antigravityUsageDataSource(from source: ProviderSourceMode?) -> AntigravityUsageDataSource { + guard let source else { return .auto } + switch source { + case .auto, .web, .api: + return .auto + case .oauth: + return .oauth + case .cli: + return .cli + } + } +} diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift new file mode 100644 index 000000000..9de353706 --- /dev/null +++ b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift @@ -0,0 +1,63 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct BedrockProviderImplementation: ProviderImplementation { + let id: UsageProvider = .bedrock + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.bedrockAccessKeyID + _ = settings.bedrockSecretAccessKey + _ = settings.bedrockRegion + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + BedrockSettingsReader.hasCredentials(environment: context.environment) + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "bedrock-access-key-id", + title: "Access key ID", + subtitle: "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID.", + kind: .secure, + placeholder: "AKIA...", + binding: context.stringBinding(\.bedrockAccessKeyID), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "bedrock-secret-access-key", + title: "Secret access key", + subtitle: "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY.", + kind: .secure, + placeholder: "", + binding: context.stringBinding(\.bedrockSecretAccessKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "bedrock-region", + title: "Region", + subtitle: "AWS region. Can also be set with AWS_REGION.", + kind: .plain, + placeholder: "us-east-1", + binding: context.stringBinding(\.bedrockRegion), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift new file mode 100644 index 000000000..9d51fc995 --- /dev/null +++ b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift @@ -0,0 +1,34 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var bedrockAccessKeyID: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .bedrock, field: "accessKeyID", value: newValue) + } + } + + var bedrockSecretAccessKey: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedSecretKey ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.secretKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .bedrock, field: "secretAccessKey", value: newValue) + } + } + + var bedrockRegion: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.region ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.region = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .bedrock, field: "region", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 9550abb05..76002520e 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -2,7 +2,7 @@ import CodexBarCore @MainActor extension StatusItemController { - func runClaudeLoginFlow() async { + func runClaudeLoginFlow() async -> Bool { let phaseHandler: @Sendable (ClaudeLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { @@ -12,14 +12,19 @@ extension StatusItemController { } } let result = await ClaudeLoginRunner.run(timeout: 120, onPhaseChange: phaseHandler) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return false } self.loginPhase = .idle self.presentClaudeLoginResult(result) let outcome = self.describe(result.outcome) let length = result.output.count self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) if case .success = result.outcome { + let metadata = self.store.metadata(for: .claude) + self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + self.settings.claudeUsageDataSource = .oauth self.postLoginNotification(for: .claude) + return true } + return false } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index cc85f8fa0..b01637a3d 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -21,11 +21,13 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.claudeUsageDataSource + _ = settings.claudeAdminAPIKey _ = settings.claudeCookieSource _ = settings.claudeCookieHeader _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudePeakHoursEnabled } @MainActor @@ -56,6 +58,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { switch context.settings.claudeUsageDataSource { case .auto: .auto + case .api: .api case .oauth: .oauth case .web: .web case .cli: .cli @@ -77,6 +80,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let peakHoursBinding = Binding( + get: { context.settings.claudePeakHoursEnabled }, + set: { context.settings.claudePeakHoursEnabled = $0 }) + return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", @@ -89,6 +96,17 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-peak-hours", + title: "Show peak hours indicator", + subtitle: "Show whether Claude is in peak usage hours.", + binding: peakHoursBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } @@ -187,20 +205,29 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - _ = context - return [] + [ + ProviderSettingsFieldDescriptor( + id: "claude-admin-api-key", + title: "Admin API key", + subtitle: "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key.", + kind: .secure, + placeholder: "sk-ant-admin...", + binding: context.stringBinding(\.claudeAdminAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] } @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runClaudeLoginFlow() - return true } @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { if context.snapshot?.secondary == nil { - entries.append(.text("Weekly usage unavailable for this account.", .secondary)) + entries.append(.text(L("Weekly usage unavailable for this account."), .secondary)) } if let cost = context.snapshot?.providerCost, @@ -209,7 +236,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("Extra usage: \(used) / \(limit)", .primary)) + entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift index 3bc55eab4..0819b7503 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift @@ -10,6 +10,7 @@ extension SettingsStore { set { let source: ProviderSourceMode? = switch newValue { case .auto: .auto + case .api: .api case .oauth: .oauth case .web: .web case .cli: .cli @@ -45,6 +46,16 @@ extension SettingsStore { } func ensureClaudeCookieLoaded() {} + + var claudeAdminAPIKey: String { + get { self.configSnapshot.providerConfig(for: .claude)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .claude) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .claude, field: "apiKey", value: newValue) + } + } } extension SettingsStore { @@ -58,14 +69,15 @@ extension SettingsStore { cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing), manualCookieHeader: self.claudeSnapshotCookieHeader( routing: routing, - hasSelectedAccount: account != nil)) + hasSelectedAccount: account != nil), + organizationID: account?.sanitizedOrganizationID) } private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource { guard let source else { return .auto } switch source { case .auto, .api: - return .auto + return source == .api ? .api : .auto case .web: return .web case .cli: @@ -84,6 +96,8 @@ extension SettingsStore { hasSelectedAccount ? "" : self.claudeCookieHeader case .oauth: "" + case .adminAPIKey: + "" case let .webCookie(header): header } @@ -102,6 +116,9 @@ extension SettingsStore { if routing.isOAuth { return .off } + if routing.adminAPIKey != nil { + return .off + } if self.tokenAccounts(for: .claude).isEmpty { return fallback } return .manual } diff --git a/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift new file mode 100644 index 000000000..6ca91443c --- /dev/null +++ b/Sources/CodexBar/Providers/Codebuff/CodebuffProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct CodebuffProviderImplementation: ProviderImplementation { + let id: UsageProvider = .codebuff + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.codebuffAPIToken + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "codebuff-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let " + + "CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`).", + kind: .secure, + placeholder: "cb_...", + binding: context.stringBinding(\.codebuffAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "codebuff-open-dashboard", + title: "Open Codebuff Dashboard", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://www.codebuff.com/usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift b/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift new file mode 100644 index 000000000..d07c8b3fa --- /dev/null +++ b/Sources/CodexBar/Providers/Codebuff/CodebuffSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var codebuffAPIToken: String { + get { self.configSnapshot.providerConfig(for: .codebuff)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .codebuff) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .codebuff, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 15d2a63b9..4b0f346e7 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -24,6 +24,15 @@ struct CodexUIErrorMapper { return "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." } + if self.looksOpenAIWebTimeout(lower: lower) { + return "OpenAI web refresh timed out. Refresh OpenAI cookies and try again." + } + + if self.looksOpenAIWebNetworkError(lower: lower) { + return "OpenAI web refresh hit a network error. " + + "Check your connection, then refresh OpenAI cookies and try again." + } + if self.looksInternalTransport(lower: lower) { return "Codex usage is temporarily unavailable. Try refreshing." } @@ -60,6 +69,10 @@ struct CodexUIErrorMapper { || lower.contains("codex credits are still loading") || lower.contains("codex account changed; importing browser cookies") || lower.contains("codex session expired. sign in again.") + || lower.contains("openai web refresh timed out. refresh openai cookies and try again.") + || lower.contains( + "openai web refresh hit a network error. " + + "check your connection, then refresh openai cookies and try again.") || lower.contains("codex usage is temporarily unavailable. try refreshing.") } @@ -84,6 +97,15 @@ struct CodexUIErrorMapper { || lower.contains("get http://") || lower.contains("returned invalid data") } + + private static func looksOpenAIWebTimeout(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + && (lower.contains("timed out") || lower.contains("error -1001")) + } + + private static func looksOpenAIWebNetworkError(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + } } struct CodexConsumerProjection { @@ -194,10 +216,12 @@ struct CodexConsumerProjection { [] } + let displayableUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) let canShowBuyCredits = surface == .liveCard let hasUsageBreakdown = surface == .liveCard && dashboardVisibility == .attached - && !(dashboard?.usageBreakdown ?? []).isEmpty + && !displayableUsageBreakdown.isEmpty let hasCreditsHistory = surface == .liveCard && dashboardVisibility == .attached && !(dashboard?.dailyBreakdown ?? []).isEmpty @@ -356,9 +380,11 @@ extension UsageStore { errorOverride: String? = nil, now: Date = Date()) -> CodexConsumerProjection { + let snapshot = surface == .overrideCard ? snapshotOverride : snapshotOverride ?? self.snapshots[.codex] + let rawUsageError = surface == .overrideCard ? errorOverride : errorOverride ?? self.errors[.codex] let context = CodexConsumerProjection.Context( - snapshot: snapshotOverride ?? self.snapshots[.codex], - rawUsageError: errorOverride ?? self.errors[.codex], + snapshot: snapshot, + rawUsageError: rawUsageError, liveCredits: self.credits, rawCreditsError: self.lastCreditsError, liveDashboard: self.openAIDashboard, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 9a39c3af1..79e3548ef 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -24,7 +24,9 @@ struct CodexProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - .codex(context.settings.codexSettingsSnapshot(tokenOverride: context.tokenOverride)) + .codex(context.settings.codexSettingsSnapshot( + tokenOverride: context.tokenOverride, + activeSourceOverride: context.codexActiveSourceOverride)) } @MainActor @@ -199,9 +201,13 @@ struct CodexProviderImplementation: ProviderImplementation { else { return } if let credits = context.store.credits { - entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) + entries.append(.text( + String(format: L("credits_remaining"), UsageFormatter.creditsString(from: credits.remaining)), + .primary)) if let latest = credits.events.first { - entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) + entries.append(.text( + String(format: L("last_spend"), UsageFormatter.creditEventSummary(latest)), + .secondary)) } } else { let hint = context.store.userFacingLastCreditsError ?? context.metadata.creditsHint @@ -235,6 +241,9 @@ struct CodexProviderImplementation: ProviderImplementation { isEnabled: isEnabled, isChecked: isChecked) } + guard submenuItems.count > 1 || submenuItems.contains(where: { $0.isEnabled && $0.action != nil }) else { + return + } entries.append(.submenu( "System Account", diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 91463ca4c..8bfb331fe 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -40,8 +40,11 @@ extension SettingsStore { return try store.loadAccounts() } - private func managedCodexAccountStoreState() -> ManagedCodexAccountStoreState { - guard case let .managedAccount(id) = self.codexResolvedActiveSource else { + private func managedCodexAccountStoreState( + activeSource: CodexActiveSource? = nil) -> ManagedCodexAccountStoreState + { + let source = activeSource ?? self.codexResolvedActiveSource + guard case let .managedAccount(id) = source else { return .none } do { @@ -64,7 +67,11 @@ extension SettingsStore { } var activeManagedCodexRemoteHomePath: String? { - guard case .managedAccount = self.codexResolvedActiveSource else { + self.managedCodexRemoteHomePath(forActiveSource: self.codexResolvedActiveSource) + } + + func managedCodexRemoteHomePath(forActiveSource source: CodexActiveSource) -> String? { + guard case let .managedAccount(id) = source else { return nil } @@ -74,10 +81,6 @@ extension SettingsStore { } #endif - guard case let .managedAccount(id) = self.codexResolvedActiveSource else { - return nil - } - do { let accounts = try self.loadManagedCodexAccounts() // A selected managed source must never fall back to ambient ~/.codex. @@ -178,7 +181,15 @@ extension SettingsStore { extension SettingsStore { var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { - self.codexAccountReconciler().loadSnapshot() + self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) + } + + func codexAccountReconciliationSnapshot( + activeSourceOverride: CodexActiveSource?) -> CodexAccountReconciliationSnapshot + { + self.codexAccountReconciler( + activeSource: activeSourceOverride ?? self.codexPersistedActiveSource) + .loadSnapshot() } var codexVisibleAccountProjection: CodexVisibleAccountProjection { @@ -196,6 +207,14 @@ extension SettingsStore { return true } + func selectDisplayedCodexVisibleAccount(_ account: CodexVisibleAccount) { + if self.selectCodexVisibleAccount(id: account.id) { + return + } + // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. + self.codexActiveSource = account.selectionSource + } + func selectAuthenticatedManagedCodexAccount(_ account: ManagedCodexAccount) { if let visibleAccountID = self.codexVisibleAccountProjection.visibleAccounts .first(where: { $0.storedAccountID == account.id })? @@ -213,7 +232,7 @@ extension SettingsStore { self.codexVisibleAccountProjection.source(forVisibleAccountID: id) } - private func codexAccountReconciler() -> DefaultCodexAccountReconciler { + private func codexAccountReconciler(activeSource: CodexActiveSource) -> DefaultCodexAccountReconciler { let baseEnvironment = self.codexReconciliationEnvironment() #if DEBUG let liveSystemAccountOverride = CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) @@ -224,7 +243,7 @@ extension SettingsStore { let unreadableStoreOverride = CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) guard CodexManagedRemoteHomeTestingOverride.hasAnyOverride(for: self) else { return DefaultCodexAccountReconciler( - activeSource: self.codexPersistedActiveSource, + activeSource: activeSource, baseEnvironment: baseEnvironment, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) @@ -254,14 +273,14 @@ extension SettingsStore { systemObserver: CodexManagedRemoteHomeTestingSystemObserver( overrideAccount: liveSystemAccountOverride, usesInjectedEnvironment: reconciliationEnvironmentOverride != nil), - activeSource: self.codexPersistedActiveSource, + activeSource: activeSource, baseEnvironment: baseEnvironment, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) #else return DefaultCodexAccountReconciler( - activeSource: self.codexPersistedActiveSource, + activeSource: activeSource, baseEnvironment: baseEnvironment, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) @@ -481,8 +500,12 @@ extension SettingsStore { #endif extension SettingsStore { - func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings { - let reconciliationSnapshot = self.codexAccountReconciliationSnapshot + func codexSettingsSnapshot( + tokenOverride: TokenAccountOverride?, + activeSourceOverride: CodexActiveSource? = nil) -> ProviderSettingsSnapshot.CodexProviderSettings + { + let reconciliationSnapshot = self.codexAccountReconciliationSnapshot( + activeSourceOverride: activeSourceOverride) let resolvedActiveSource = CodexActiveSourceResolver.resolve(from: reconciliationSnapshot) return CodexProviderSettingsBuilder.make(input: CodexProviderSettingsBuilderInput( usageDataSource: self.codexUsageDataSource, diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 03e42f23d..0952a47ae 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -68,6 +68,7 @@ extension UsageStore { self.lastSourceLabels.removeValue(forKey: .codex) self.lastFetchAttempts.removeValue(forKey: .codex) self.accountSnapshots.removeValue(forKey: .codex) + self.codexAccountSnapshots = [] self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index 300331ad0..d57963715 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -101,8 +101,7 @@ extension UsageStore { if let override = self._test_codexCreditsLoaderOverride { return try await override() } - return try await self.codexCreditsFetcher().loadLatestCredits( - keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) + return try await self.codexCreditsFetcher().loadLatestCredits() } func waitForCodexSnapshot(minimumUpdatedAt: Date) async -> UsageSnapshot? { diff --git a/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift b/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift new file mode 100644 index 000000000..4d2e0438a --- /dev/null +++ b/Sources/CodexBar/Providers/CommandCode/CommandCodeProviderImplementation.swift @@ -0,0 +1,81 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct CommandCodeProviderImplementation: ProviderImplementation { + let id: UsageProvider = .commandcode + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.commandcodeCookieSource + _ = settings.commandcodeCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .commandcode(context.settings.commandcodeSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.commandcodeCookieSource.rawValue }, + set: { raw in + context.settings.commandcodeCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.commandcodeCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from Command Code.", + off: "Command Code cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "commandcode-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "commandcode-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: …", + binding: context.stringBinding(\.commandcodeCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "commandcode-open-settings", + title: "Open Command Code Settings", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://commandcode.ai/studio") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.commandcodeCookieSource == .manual }, + onActivate: { context.settings.ensureCommandCodeCookieLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift b/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift new file mode 100644 index 000000000..592dc077d --- /dev/null +++ b/Sources/CodexBar/Providers/CommandCode/CommandCodeSettingsStore.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var commandcodeCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .commandcode)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .commandcode) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .commandcode, field: "cookieHeader", value: newValue) + } + } + + var commandcodeCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .commandcode, fallback: .auto) } + set { + self.updateProviderConfig(provider: .commandcode) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .commandcode, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureCommandCodeCookieLoaded() {} +} + +extension SettingsStore { + func commandcodeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .CommandCodeProviderSettings { + ProviderSettingsSnapshot.CommandCodeProviderSettings( + cookieSource: self.commandcodeSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.commandcodeSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func commandcodeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.commandcodeCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .commandcode), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .commandcode, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func commandcodeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.commandcodeCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .commandcode), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .commandcode).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 1170f2236..14374c5f2 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -5,7 +5,8 @@ import SwiftUI @MainActor struct CopilotLoginFlow { static func run(settings: SettingsStore) async { - let flow = CopilotDeviceFlow() + let enterpriseHost = settings.copilotEnterpriseHost + let flow = CopilotDeviceFlow(enterpriseHost: enterpriseHost.isEmpty ? nil : enterpriseHost) do { let code = try await flow.requestDeviceCode() @@ -16,14 +17,10 @@ struct CopilotLoginFlow { pb.setString(code.userCode, forType: .string) let alert = NSAlert() - alert.messageText = "GitHub Copilot Login" - alert.informativeText = """ - A device code has been copied to your clipboard: \(code.userCode) - - Please verify it at: \(code.verificationUri) - """ - alert.addButton(withTitle: "Open Browser") - alert.addButton(withTitle: "Cancel") + alert.messageText = L("GitHub Copilot Login") + alert.informativeText = String(format: L("copilot_device_code"), code.userCode, code.verificationUri) + alert.addButton(withTitle: L("Open Browser")) + alert.addButton(withTitle: L("Cancel")) let response = alert.runModal() if response == .alertSecondButtonReturn { @@ -43,12 +40,9 @@ struct CopilotLoginFlow { // Let's show a "Waiting" alert that can be cancelled. let waitingAlert = NSAlert() - waitingAlert.messageText = "Waiting for Authentication..." - waitingAlert.informativeText = """ - Please complete the login in your browser. - This window will close automatically when finished. - """ - waitingAlert.addButton(withTitle: "Cancel") + waitingAlert.messageText = L("Waiting for Authentication...") + waitingAlert.informativeText = L("copilot_waiting_text") + waitingAlert.addButton(withTitle: L("Cancel")) let parentWindow = Self.resolveWaitingParentWindow() let hostWindow = parentWindow ?? Self.makeWaitingHostWindow() let shouldCloseHostWindow = parentWindow == nil @@ -80,14 +74,72 @@ struct CopilotLoginFlow { switch tokenResult { case let .success(token): - settings.copilotAPIToken = token + // Fetch username for account label. + // If accounts already exist, fail closed when identity lookup fails so re-auth cannot create + // an anonymous duplicate with stale credentials left on the original account. + let existingAccounts = settings.tokenAccounts(for: .copilot) + let label: String + let identity: CopilotUsageFetcher.GitHubUserIdentity? + do { + let resolvedIdentity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + let resolvedUsername = resolvedIdentity.login + let planSuffix: String + do { + let fetcher = CopilotUsageFetcher( + token: token, + enterpriseHost: enterpriseHost.isEmpty ? nil : enterpriseHost) + let usage = try await fetcher.fetch() + let plan = usage.identity(for: .copilot)?.loginMethod ?? "" + planSuffix = plan.isEmpty ? "" : " (\(plan))" + } catch { + planSuffix = "" + } + identity = resolvedIdentity + label = "\(resolvedUsername)\(planSuffix)" + } catch { + guard existingAccounts.isEmpty else { + let err = NSAlert() + err.messageText = "Could Not Identify GitHub Account" + err.informativeText = "GitHub login succeeded, but CodexBar could not verify which " + + "account it belongs to. Please try again." + err.runModal() + return + } + identity = nil + label = "Account 1" + } + + // Match existing account by stable GitHub user ID. For legacy accounts that pre-date stable + // identifiers, also accept login-based externalIdentifier values and resolve stored token identity + // before falling back to labels. + let matchedExisting = await Self.matchExistingAccount( + existingAccounts: existingAccounts, + identity: identity, + label: label) + let externalIdentifier = identity.map(Self.externalIdentifier) + let wasRefresh = matchedExisting != nil + if let existing = matchedExisting { + settings.updateTokenAccount( + provider: .copilot, + accountID: existing.id, + label: label, + token: token, + externalIdentifier: .some(externalIdentifier)) + } else { + settings.addTokenAccount( + provider: .copilot, + label: label, + token: token, + externalIdentifier: externalIdentifier) + } settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" + success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } @@ -105,6 +157,73 @@ struct CopilotLoginFlow { } } + static func matchExistingAccount( + existingAccounts: [ProviderTokenAccount], + identity: CopilotUsageFetcher.GitHubUserIdentity?, + label: String, + legacyIdentityResolver: @escaping @Sendable (ProviderTokenAccount) async + -> CopilotUsageFetcher.GitHubUserIdentity? = { account in + try? await CopilotUsageFetcher.fetchGitHubIdentity(token: account.token) + }) async -> ProviderTokenAccount? + { + guard let identity, !existingAccounts.isEmpty else { return nil } + let stableIdentifier = self.externalIdentifier(for: identity) + let login = self.normalizedGitHubLogin(identity.login) + + if let byID = existingAccounts.first(where: { account in + self.normalizedExternalIdentifier(account.externalIdentifier) == stableIdentifier + }) { + return byID + } + + // Previous PR revisions stored GitHub login in externalIdentifier. Keep matching those + // accounts case-insensitively, then write back the stable ID on update. + if let byLegacyLogin = existingAccounts.first(where: { account in + self.normalizedGitHubLogin(account.externalIdentifier) == login + }) { + return byLegacyLogin + } + + let legacyAccounts = existingAccounts.filter { $0.externalIdentifier == nil } + for account in legacyAccounts { + guard let resolvedIdentity = await legacyIdentityResolver(account) else { continue } + if resolvedIdentity.id == identity.id || + self.normalizedGitHubLogin(resolvedIdentity.login) == login + { + return account + } + } + + let usernamePrefix = self.displayLabelPrefix(label) + return legacyAccounts.first { account in + self.displayLabelPrefix(account.label) == usernamePrefix + } + } + + static func externalIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { + "github:user:\(identity.id)" + } + + private static func normalizedExternalIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func normalizedGitHubLogin(_ login: String?) -> String? { + let trimmed = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + // Stable IDs are not valid GitHub logins; do not let a numeric-looking login fallback + // match the "github:user:" identifier path accidentally. + guard !trimmed.lowercased().hasPrefix("github:user:") else { return nil } + return trimmed.lowercased() + } + + private static func displayLabelPrefix(_ label: String) -> String { + (label.components(separatedBy: " (").first ?? label) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + @MainActor private static func presentWaitingAlert( _ alert: NSAlert, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 6d400417c..660a2d230 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -16,45 +16,53 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.copilotAPIToken + _ = settings.copilotEnterpriseHost } @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - _ = context - return .copilot(context.settings.copilotSettingsSnapshot()) + .copilot(context.settings.copilotSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Add Account...", .addProviderAccount(.copilot)) } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( - id: "copilot-api-token", + id: "copilot-enterprise-host", + title: "Enterprise host", + subtitle: "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com.", + kind: .plain, + placeholder: "github.com", + binding: context.stringBinding(\.copilotEnterpriseHost), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "copilot-add-account", title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", - footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.", - kind: .secure, - placeholder: "Sign in via button below", - binding: context.stringBinding(\.copilotAPIToken), + subtitle: "Add accounts via GitHub OAuth Device Flow on the selected host.", + kind: .plain, + placeholder: nil, + binding: .constant(""), actions: [ ProviderSettingsActionDescriptor( - id: "copilot-login", - title: "Sign in with GitHub", + id: "copilot-add-account-action", + title: "Add Account", style: .bordered, - isVisible: { context.settings.copilotAPIToken.isEmpty }, - perform: { - await CopilotLoginFlow.run(settings: context.settings) - }), - ProviderSettingsActionDescriptor( - id: "copilot-relogin", - title: "Sign in again", - style: .link, - isVisible: { !context.settings.copilotAPIToken.isEmpty }, + isVisible: { true }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ], isVisible: nil, - onActivate: { context.settings.ensureCopilotAPITokenLoaded() }), + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index c40a51f3d..38c875e8b 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -12,11 +12,30 @@ extension SettingsStore { } } + var copilotEnterpriseHost: String { + get { self.configSnapshot.providerConfig(for: .copilot)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .copilot) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } + func ensureCopilotAPITokenLoaded() {} } extension SettingsStore { - func copilotSettingsSnapshot() -> ProviderSettingsSnapshot.CopilotProviderSettings { - ProviderSettingsSnapshot.CopilotProviderSettings() + func copilotSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CopilotProviderSettings + { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .copilot, + settings: self, + override: tokenOverride) + let token = account?.token ?? self.copilotAPIToken + let host = CopilotDeviceFlow.normalizedHost(self.copilotEnterpriseHost) + return ProviderSettingsSnapshot.CopilotProviderSettings( + apiToken: self.normalizedConfigValue(token), + enterpriseHost: host == CopilotDeviceFlow.defaultHost ? nil : host) } } diff --git a/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift b/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift new file mode 100644 index 000000000..3e7165080 --- /dev/null +++ b/Sources/CodexBar/Providers/Crof/CrofProviderImplementation.swift @@ -0,0 +1,54 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct CrofProviderImplementation: ProviderImplementation { + let id: UsageProvider = .crof + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.crofAPIToken + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if CrofSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.crofAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "crof-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY.", + kind: .secure, + placeholder: "crof_...", + binding: context.stringBinding(\.crofAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "crof-open-dashboard", + title: "Open Crof dashboard", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://crof.ai/dashboard") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Crof/CrofSettingsStore.swift b/Sources/CodexBar/Providers/Crof/CrofSettingsStore.swift new file mode 100644 index 000000000..86c152ea0 --- /dev/null +++ b/Sources/CodexBar/Providers/Crof/CrofSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var crofAPIToken: String { + get { self.configSnapshot.providerConfig(for: .crof)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .crof) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .crof, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift index ff65fb539..04f75022d 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift @@ -4,14 +4,12 @@ import CodexBarCore extension StatusItemController { func runCursorLoginFlow() async { let cursorRunner = CursorLoginRunner(browserDetection: self.store.browserDetection) - let phaseHandler: @Sendable (CursorLoginRunner.Phase) -> Void = { [weak self] phase in - Task { @MainActor in - switch phase { - case .loading, .waitingLogin: - self?.loginPhase = .waitingBrowser - case .success, .failed: - self?.loginPhase = .idle - } + let phaseHandler: @MainActor (CursorLoginRunner.Phase) -> Void = { [weak self] phase in + switch phase { + case .loading, .waitingLogin: + self?.loginPhase = .waitingBrowser + case .success, .failed: + self?.loginPhase = .idle } } let result = await cursorRunner.run(onPhaseChange: phaseHandler) diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 48db614f9..a8638af89 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -94,9 +94,9 @@ struct CursorProviderImplementation: ProviderImplementation { let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) if cost.limit > 0 { let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary)) + entries.append(.text(String(format: L("cursor_on_demand_with_limit"), used, limitStr), .primary)) } else { - entries.append(.text("On-Demand: \(used)", .primary)) + entries.append(.text(String(format: L("cursor_on_demand"), used), .primary)) } } } diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift new file mode 100644 index 000000000..e72ec76f0 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct DeepSeekProviderImplementation: ProviderImplementation { + let id: UsageProvider = .deepseek + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_: SettingsStore) {} + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.tokenAccounts(for: .deepseek).isEmpty + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift b/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift new file mode 100644 index 000000000..54616537c --- /dev/null +++ b/Sources/CodexBar/Providers/Deepgram/DeepgramProviderImplementation.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct DeepgramProviderImplementation: ProviderImplementation { + let id: UsageProvider = .deepgram + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.deepgramAPIKey + _ = settings.deepgramProjectID + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if DeepgramSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.deepgramAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "deepgram-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com.", + kind: .secure, + placeholder: "dg_...", + binding: context.stringBinding(\.deepgramAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "deepgram-project-id", + title: "Project ID", + subtitle: "Optional. Leave blank to discover and aggregate projects visible to the API key.", + kind: .plain, + placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + binding: context.stringBinding(\.deepgramProjectID), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Deepgram/DeepgramSettingsStore.swift b/Sources/CodexBar/Providers/Deepgram/DeepgramSettingsStore.swift new file mode 100644 index 000000000..e3588596d --- /dev/null +++ b/Sources/CodexBar/Providers/Deepgram/DeepgramSettingsStore.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var deepgramAPIKey: String { + get { + self.configSnapshot.providerConfig(for: .deepgram)?.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .deepgram) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .deepgram, field: "apiKey", value: newValue) + } + } + + var deepgramProjectID: String { + get { + self.configSnapshot.providerConfig(for: .deepgram)?.sanitizedWorkspaceID ?? "" + } + set { + self.updateProviderConfig(provider: .deepgram) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + } + } +} diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift new file mode 100644 index 000000000..fe974bed8 --- /dev/null +++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift @@ -0,0 +1,42 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct DoubaoProviderImplementation: ProviderImplementation { + let id: UsageProvider = .doubao + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.doubaoAPIToken + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "doubao-api-token", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine " + + "Ark console.", + kind: .secure, + placeholder: "ark-...", + binding: context.stringBinding(\.doubaoAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "doubao-open-dashboard", + title: "Open Volcengine Ark Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://console.volcengine.com/ark/") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift new file mode 100644 index 000000000..4d69a273f --- /dev/null +++ b/Sources/CodexBar/Providers/Doubao/DoubaoSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var doubaoAPIToken: String { + get { self.configSnapshot.providerConfig(for: .doubao)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .doubao) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .doubao, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift new file mode 100644 index 000000000..d6b4c285e --- /dev/null +++ b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsProviderImplementation.swift @@ -0,0 +1,45 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct ElevenLabsProviderImplementation: ProviderImplementation { + let id: UsageProvider = .elevenlabs + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.elevenLabsAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if ElevenLabsSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if !context.settings.elevenLabsAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return true + } + return !context.settings.tokenAccounts(for: .elevenlabs).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "elevenlabs-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys.", + kind: .secure, + placeholder: "xi-...", + binding: context.stringBinding(\.elevenLabsAPIKey), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsSettingsStore.swift b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsSettingsStore.swift new file mode 100644 index 000000000..4ebdc787f --- /dev/null +++ b/Sources/CodexBar/Providers/ElevenLabs/ElevenLabsSettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var elevenLabsAPIKey: String { + get { self.configSnapshot.providerConfig(for: .elevenlabs)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .elevenlabs) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .elevenlabs, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index c4fd16117..c9a759cba 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -49,7 +49,7 @@ struct FactoryProviderImplementation: ProviderImplementation { source: context.settings.factoryCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and WorkOS tokens.", - manual: "Paste a Cookie header from app.factory.ai.", + manual: "Paste a Cookie or Authorization header from app.factory.ai.", off: "Factory cookies are disabled.") } @@ -89,4 +89,15 @@ struct FactoryProviderImplementation: ProviderImplementation { { ("Open Droid in Browser...", .loginToProvider(url: "https://app.factory.ai")) } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard context.settings.showOptionalCreditsAndExtraUsage, + let cost = context.snapshot?.providerCost, + cost.period == "Extra usage balance" + else { return } + + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + entries.append(.text("Extra usage balance: \(balance)", .primary)) + } } diff --git a/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift new file mode 100644 index 000000000..e37cd9bb0 --- /dev/null +++ b/Sources/CodexBar/Providers/Grok/GrokProviderImplementation.swift @@ -0,0 +1,8 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct GrokProviderImplementation: ProviderImplementation { + let id: UsageProvider = .grok +} diff --git a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift index bfb92438e..e188c04bb 100644 --- a/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift +++ b/Sources/CodexBar/Providers/JetBrains/JetBrainsLoginFlow.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation @MainActor extension StatusItemController { @@ -17,10 +18,10 @@ extension StatusItemController { let ideNames = detectedIDEs.prefix(3).map(\.displayName).joined(separator: ", ") let hasQuotaFile = !JetBrainsIDEDetector.detectInstalledIDEs().isEmpty let message = hasQuotaFile - ? "Detected: \(ideNames). Select your preferred IDE in Settings, then refresh CodexBar." - : "Detected: \(ideNames). Use AI Assistant once to generate quota data, then refresh CodexBar." + ? String(format: L("jetbrains_detected_select"), ideNames) + : String(format: L("jetbrains_detected_generate"), ideNames) self.presentLoginAlert( - title: "JetBrains AI is ready", + title: L("JetBrains AI is ready"), message: message) } } diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index e2bdb3cfa..8038ddb81 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -84,4 +84,84 @@ struct KiloProviderImplementation: ProviderImplementation { onActivate: nil), ] } + + @MainActor + func settingsOrganizations( + context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? + { + let settings = context.settings + let store = context.store + return ProviderSettingsOrganizationsDescriptor( + id: "kilo-organizations", + title: "Organizations", + subtitle: "Show usage for organizations you belong to. Personal account is always shown.", + entries: { + var entries: [ProviderSettingsOrganizationsDescriptor.Entry] = [ + .init( + id: "personal", + title: "Personal account", + subtitle: nil, + isEnabled: true, + isLocked: true), + ] + for org in settings.kiloKnownOrganizations { + entries.append( + .init( + id: org.id, + title: org.name, + subtitle: org.role, + isEnabled: settings.kiloIsOrganizationEnabled(org.id), + isLocked: false)) + } + return entries + }, + onToggle: { orgID, enabled in + guard orgID != "personal" else { return } + settings.setKiloOrganization(orgID, enabled: enabled) + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refreshProvider(.kilo, allowDisabled: true) + } + } + }, + onRefresh: { [weak settings] in + guard let settings else { + return .init(success: false, errorMessage: "Settings unavailable.") + } + let resolved: KiloResolvedBearerToken + do { + resolved = try KiloBearerTokenResolver.resolve( + source: settings.kiloUsageDataSource, + apiKey: settings.configSnapshot.providerConfig(for: .kilo)?.sanitizedAPIKey) + } catch let error as LocalizedError { + return .init( + success: false, + errorMessage: error.errorDescription ?? "Failed to resolve Kilo credentials.") + } catch { + return .init(success: false, errorMessage: error.localizedDescription) + } + do { + let orgs = try await KiloUsageFetcher.fetchOrganizations(apiKey: resolved.token) + await MainActor.run { + settings.setKiloKnownOrganizationsPruningEnabled(orgs) + } + return .init(success: true, errorMessage: nil) + } catch let error as LocalizedError { + return .init( + success: false, + errorMessage: error.errorDescription ?? "Failed to load organizations.") + } catch { + return .init(success: false, errorMessage: error.localizedDescription) + } + }, + canRefresh: { + switch settings.kiloUsageDataSource { + case .api: + !settings.kiloAPIToken.isEmpty + || !(ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] ?? "").isEmpty + case .cli, .auto: + true + } + }) + } } diff --git a/Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift b/Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift index d900bf4dc..50fc27912 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift @@ -73,3 +73,70 @@ extension SettingsStore { } } } + +extension SettingsStore { + var kiloKnownOrganizations: [KiloOrganization] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloKnownOrganizations ?? [] } + set { + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloKnownOrganizations = newValue.isEmpty ? nil : newValue + } + } + } + + var kiloEnabledOrganizationIDs: [String] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloEnabledOrganizationIDs ?? [] } + set { + let cleaned = Array(KiloOrgIDLinkedHashSet(newValue + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty })) + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloEnabledOrganizationIDs = cleaned.isEmpty ? nil : cleaned + } + self.logProviderModeChange( + provider: .kilo, + field: "enabledOrganizations", + value: cleaned.joined(separator: ",")) + } + } + + func setKiloKnownOrganizationsPruningEnabled(_ orgs: [KiloOrganization]) { + self.kiloKnownOrganizations = orgs + let validIDs = Set(orgs.map(\.id)) + let pruned = self.kiloEnabledOrganizationIDs.filter { validIDs.contains($0) } + if pruned != self.kiloEnabledOrganizationIDs { + self.kiloEnabledOrganizationIDs = pruned + } + } + + func kiloIsOrganizationEnabled(_ orgID: String) -> Bool { + self.kiloEnabledOrganizationIDs.contains(orgID) + } + + func setKiloOrganization(_ orgID: String, enabled: Bool) { + var current = self.kiloEnabledOrganizationIDs + if enabled { + guard !current.contains(orgID) else { return } + current.append(orgID) + } else { + current.removeAll { $0 == orgID } + } + self.kiloEnabledOrganizationIDs = current + } +} + +/// Small order-preserving set used to dedupe enabled IDs without sorting. +private struct KiloOrgIDLinkedHashSet: Sequence { + private var seen: Set = [] + private var ordered: [Element] = [] + + init(_ sequence: some Sequence) { + for element in sequence where self.seen.insert(element).inserted { + self.ordered.append(element) + } + } + + func makeIterator() -> IndexingIterator<[Element]> { + self.ordered.makeIterator() + } +} diff --git a/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift new file mode 100644 index 000000000..ed1f7129e --- /dev/null +++ b/Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift @@ -0,0 +1,131 @@ +import CodexBarCore +import Foundation + +struct KiloScopeSnapshot: Identifiable, Equatable { + let id: String // KiloUsageScope.scopeIdentifier + let scope: KiloUsageScope + let snapshot: UsageSnapshot? + let errorMessage: String? + let sourceLabel: String? + + static func == (lhs: KiloScopeSnapshot, rhs: KiloScopeSnapshot) -> Bool { + lhs.id == rhs.id + && lhs.snapshot?.updatedAt == rhs.snapshot?.updatedAt + && lhs.errorMessage == rhs.errorMessage + && lhs.sourceLabel == rhs.sourceLabel + } +} + +extension UsageStore { + var kiloEnabledScopes: [KiloUsageScope] { + var scopes: [KiloUsageScope] = [.personal] + let enabled = self.settings.kiloEnabledOrganizationIDs + guard !enabled.isEmpty else { return scopes } + let knownByID = Dictionary( + uniqueKeysWithValues: self.settings.kiloKnownOrganizations.map { ($0.id, $0) }) + for id in enabled { + if let org = knownByID[id] { + scopes.append(.organization(id: org.id, name: org.name)) + } + } + return scopes + } + + func shouldFanOutKiloScopes() -> Bool { + self.kiloEnabledScopes.count > 1 + } + + func refreshKiloScopes() async { + let scopes = self.kiloEnabledScopes + guard scopes.count > 1 else { + await MainActor.run { self.kiloScopeSnapshots = [] } + return + } + let env = ProcessInfo.processInfo.environment + let resolved: KiloResolvedBearerToken + do { + resolved = try KiloBearerTokenResolver.resolve( + source: self.settings.kiloUsageDataSource, + apiKey: self.settings.configSnapshot.providerConfig(for: .kilo)?.sanitizedAPIKey, + environment: env) + } catch { + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + await MainActor.run { + self.kiloScopeSnapshots = scopes.map { + KiloScopeSnapshot( + id: $0.scopeIdentifier, + scope: $0, + snapshot: nil, + errorMessage: message, + sourceLabel: nil) + } + } + return + } + + let results: [KiloScopeSnapshot] = await withTaskGroup(of: KiloScopeSnapshot.self) { group in + for scope in scopes { + group.addTask { + do { + let raw = try await KiloUsageFetcher.fetchUsage( + apiKey: resolved.token, + scope: scope, + environment: env) + let snapshot = raw.toUsageSnapshot() + .withAccountOrganization(scope.displayName) + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: snapshot, + errorMessage: nil, + sourceLabel: resolved.sourceLabel) + } catch { + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: nil, + errorMessage: (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription, + sourceLabel: nil) + } + } + } + var collected: [KiloScopeSnapshot] = [] + for await result in group { + collected.append(result) + } + return collected + } + + let resultByID = Dictionary(uniqueKeysWithValues: results.map { ($0.id, $0) }) + let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } + + await MainActor.run { + self.kiloScopeSnapshots = ordered + } + } +} + +extension UsageSnapshot { + fileprivate func withAccountOrganization(_ org: String) -> UsageSnapshot { + let baseIdentity = self.identity + let newIdentity = ProviderIdentitySnapshot( + providerID: baseIdentity?.providerID ?? .kilo, + accountEmail: baseIdentity?.accountEmail, + accountOrganization: org, + loginMethod: baseIdentity?.loginMethod) + return UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + extraRateWindows: self.extraRateWindows, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + openRouterUsage: self.openRouterUsage, + cursorRequests: self.cursorRequests, + updatedAt: self.updatedAt, + identity: newIdentity) + } +} diff --git a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift index 9209bba71..07c643c23 100644 --- a/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/KimiK2/KimiK2ProviderImplementation.swift @@ -18,18 +18,18 @@ struct KimiK2ProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "kimi-k2-api-token", title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai.", + subtitle: "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API.", kind: .secure, placeholder: "Paste API key…", binding: context.stringBinding(\.kimiK2APIToken), actions: [ ProviderSettingsActionDescriptor( id: "kimi-k2-open-api-keys", - title: "Open API Keys", + title: "Open legacy provider docs", style: .link, isVisible: nil, perform: { - if let url = URL(string: "https://kimi-k2.ai/user-center/api-keys") { + if let url = URL(string: "https://github.com/steipete/CodexBar/blob/main/docs/kimi-k2.md") { NSWorkspace.shared.open(url) } }), diff --git a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift index e54bc4290..170adeeeb 100644 --- a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift @@ -1,8 +1,29 @@ import CodexBarCore import CodexBarMacroSupport import Foundation +import SwiftUI @ProviderImplementationRegistration struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro + + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [ + ProviderSettingsPickerDescriptor( + id: "kiroMenuBarDisplay", + title: "Kiro menu bar value", + subtitle: "Show or hide Kiro credits, percent, or both next to the menu bar icon.", + binding: Binding( + get: { context.settings.kiroMenuBarDisplayMode.rawValue }, + set: { rawValue in + guard let mode = KiroMenuBarDisplayMode(rawValue: rawValue) else { return } + context.settings.kiroMenuBarDisplayMode = mode + }), + options: KiroMenuBarDisplayMode.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.label) + }, + isVisible: { true }, + onChange: nil), + ] + } } diff --git a/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift new file mode 100644 index 000000000..873ffca56 --- /dev/null +++ b/Sources/CodexBar/Providers/Manus/ManusProviderImplementation.swift @@ -0,0 +1,109 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct ManusProviderImplementation: ProviderImplementation { + let id: UsageProvider = .manus + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + if let url = URL(string: "https://manus.im") { + NSWorkspace.shared.open(url) + } + return false + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.manusCookieSource + _ = settings.manusManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .manus(context.settings.manusSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.manusCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.manusCookieSource != .manual { + settings.manusCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.manusCookieSource.rawValue }, + set: { raw in + context.settings.manusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.manusCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports browser session cookies.", + manual: "Paste the session_id value or a full Cookie header.", + off: "Manus cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "manus-cookie-source", + title: "Cookie source", + subtitle: "Automatically imports browser session cookies.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "manus-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "session_id=...\n\nor paste just the session_id value", + binding: context.stringBinding(\.manusManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "manus-open-dashboard", + title: "Open Manus", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://manus.im") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.manusCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift new file mode 100644 index 000000000..1b4573a1b --- /dev/null +++ b/Sources/CodexBar/Providers/Manus/ManusSettingsStore.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var manusManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .manus)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .manus) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .manus, field: "cookieHeader", value: newValue) + } + } + + var manusCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .manus, fallback: .auto) } + set { + self.updateProviderConfig(provider: .manus) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .manus, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func manusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ManusProviderSettings { + ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: self.manusSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.manusSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private func manusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.manusManualCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .manus), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .manus, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func manusSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.manusCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .manus), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .manus).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift new file mode 100644 index 000000000..bcb9b68cf --- /dev/null +++ b/Sources/CodexBar/Providers/MiMo/MiMoProviderImplementation.swift @@ -0,0 +1,102 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct MiMoProviderImplementation: ProviderImplementation { + let id: UsageProvider = .mimo + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.miMoCookieSource + _ = settings.miMoCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .mimo(context.settings.miMoSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.miMoCookieSource.rawValue }, + set: { raw in + context.settings.miMoCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.miMoCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + manual: "Paste a Cookie header from platform.xiaomimimo.com.", + off: "Xiaomi MiMo cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "mimo-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports Chrome browser cookies from Xiaomi MiMo.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .mimo) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "mimo-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.miMoCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "mimo-open-balance", + title: "Open MiMo Balance", + style: .link, + isVisible: nil, + perform: { + guard let url = URL(string: "https://platform.xiaomimimo.com/#/console/balance") else { + return + } + NSWorkspace.shared.open(url) + }), + ], + isVisible: { context.settings.miMoCookieSource == .manual }, + onActivate: { context.settings.ensureMiMoCookieLoaded() }), + ] + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + let loginURL = "https://platform.xiaomimimo.com/api/v1/genLoginUrl?currentPath=%2F%23%2Fconsole%2Fbalance" + guard let url = URL(string: loginURL) else { + return false + } + NSWorkspace.shared.open(url) + return false + } +} diff --git a/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift new file mode 100644 index 000000000..3285a20b3 --- /dev/null +++ b/Sources/CodexBar/Providers/MiMo/MiMoSettingsStore.swift @@ -0,0 +1,35 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var miMoCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .mimo)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .mimo) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .mimo, field: "cookieHeader", value: newValue) + } + } + + var miMoCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .mimo, fallback: .auto) } + set { + self.updateProviderConfig(provider: .mimo) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .mimo, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureMiMoCookieLoaded() {} +} + +extension SettingsStore { + func miMoSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.MiMoProviderSettings { + _ = tokenOverride + return ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: self.miMoCookieSource, + manualCookieHeader: self.miMoCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 6b7432edc..661441e99 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -65,7 +65,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and local storage tokens.", - manual: "Paste a Cookie header or cURL capture from the Coding Plan page.", + manual: "Paste a Cookie header or cURL capture from the Token Plan page.", off: "MiniMax cookies are disabled.") } @@ -122,7 +122,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard", - title: "Open Coding Plan", + title: "Open Token Plan", style: .link, isVisible: nil, perform: { @@ -141,7 +141,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard-cookie", - title: "Open Coding Plan", + title: "Open Token Plan", style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift new file mode 100644 index 000000000..67e862c40 --- /dev/null +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift @@ -0,0 +1,88 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct MoonshotProviderImplementation: ProviderImplementation { + let id: UsageProvider = .moonshot + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.moonshotAPIToken + _ = settings.moonshotRegion + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) + -> ProviderSettingsSnapshotContribution? + { + .moonshot(context.settings.moonshotSettingsSnapshot()) + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if MoonshotSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + context.settings.ensureMoonshotAPITokenLoaded() + return !context.settings.moonshotAPIToken.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let binding = Binding( + get: { context.settings.moonshotRegion.rawValue }, + set: { raw in + context.settings.moonshotRegion = MoonshotRegion(rawValue: raw) ?? .international + }) + let options = MoonshotRegion.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + + return [ + ProviderSettingsPickerDescriptor( + id: "moonshot-api-region", + title: "API region", + subtitle: "Choose the Moonshot/Kimi API host for international or China mainland accounts.", + binding: binding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "moonshot-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.moonshotAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "moonshot-open-dashboard", + title: "Open Moonshot Console", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.moonshot.ai/console/account") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureMoonshotAPITokenLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift b/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift new file mode 100644 index 000000000..beb808af3 --- /dev/null +++ b/Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift @@ -0,0 +1,44 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var moonshotAPIToken: String { + get { self.configSnapshot.providerConfig(for: .moonshot)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .moonshot) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .moonshot, field: "apiKey", value: newValue) + } + } + + var moonshotRegion: MoonshotRegion { + get { + let raw = self.configSnapshot.providerConfig(for: .moonshot)?.region + return MoonshotRegion(rawValue: raw ?? "") ?? .international + } + set { + self.updateProviderConfig(provider: .moonshot) { entry in + entry.region = newValue.rawValue + } + } + } + + func ensureMoonshotAPITokenLoaded() {} + + var configuredMoonshotRegion: MoonshotRegion? { + guard let raw = self.configSnapshot.providerConfig(for: .moonshot)?.region? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return nil + } + return MoonshotRegion(rawValue: raw) + } +} + +extension SettingsStore { + func moonshotSettingsSnapshot() -> ProviderSettingsSnapshot.MoonshotProviderSettings { + ProviderSettingsSnapshot.MoonshotProviderSettings(region: self.configuredMoonshotRegion) + } +} diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift new file mode 100644 index 000000000..738c26eec --- /dev/null +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift @@ -0,0 +1,57 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct OpenAIAPIProviderImplementation: ProviderImplementation { + let id: UsageProvider = .openai + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.openAIAPIKey + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if OpenAIAPISettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "openai-api-key", + title: "Admin API key", + subtitle: "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; " + + "OPENAI_API_KEY still works.", + kind: .secure, + placeholder: "sk-admin-...", + binding: context.stringBinding(\.openAIAPIKey), + actions: [ + ProviderSettingsActionDescriptor( + id: "openai-open-billing", + title: "Open billing", + style: .link, + isVisible: nil, + perform: { + if let url = URL( + string: "https://platform.openai.com/settings/organization/billing/overview") + { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift new file mode 100644 index 000000000..b293a75bd --- /dev/null +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift @@ -0,0 +1,14 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var openAIAPIKey: String { + get { self.configSnapshot.providerConfig(for: .openai)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .openai) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .openai, field: "apiKey", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift index 30b645e86..3f77ff2b2 100644 --- a/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenCodeGo/OpenCodeGoSettingsStore.swift @@ -33,6 +33,10 @@ extension SettingsStore { } } + var opencodegoDashboardURL: URL { + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: self.opencodegoWorkspaceID) + } + func ensureOpenCodeGoCookieLoaded() {} } diff --git a/Sources/CodexBar/Providers/Shared/ProviderContext.swift b/Sources/CodexBar/Providers/Shared/ProviderContext.swift index 8b0069a6a..57493fe86 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderContext.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderContext.swift @@ -34,4 +34,15 @@ struct ProviderVersionContext { struct ProviderSettingsSnapshotContext { let settings: SettingsStore let tokenOverride: TokenAccountOverride? + let codexActiveSourceOverride: CodexActiveSource? + + init( + settings: SettingsStore, + tokenOverride: TokenAccountOverride?, + codexActiveSourceOverride: CodexActiveSource? = nil) + { + self.settings = settings + self.tokenOverride = tokenOverride + self.codexActiveSourceOverride = codexActiveSourceOverride + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift index 7d5e22bd2..84e709525 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -42,10 +42,18 @@ protocol ProviderImplementation: Sendable { @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] + /// Optional provider-specific settings action rows to render in the Providers pane. + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] + /// Optional provider-specific settings pickers to render in the Providers pane. @MainActor func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] + /// Optional provider-specific organizations selection rendered in the Providers pane. + @MainActor + func settingsOrganizations(context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? + /// Optional visibility gate for token account settings. @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool @@ -129,11 +137,21 @@ extension ProviderImplementation { [] } + @MainActor + func settingsActions(context _: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + [] + } + @MainActor func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { [] } + @MainActor + func settingsOrganizations(context _: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? { + nil + } + @MainActor func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { guard support.requiresManualCookieSource else { return true } diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 1b4950ec3..14d383d66 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -14,6 +14,7 @@ enum ProviderImplementationRegistry { private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) { switch provider { case .codex: CodexProviderImplementation() + case .openai: OpenAIAPIProviderImplementation() case .claude: ClaudeProviderImplementation() case .cursor: CursorProviderImplementation() case .opencode: OpenCodeProviderImplementation() @@ -25,6 +26,7 @@ enum ProviderImplementationRegistry { case .copilot: CopilotProviderImplementation() case .zai: ZaiProviderImplementation() case .minimax: MiniMaxProviderImplementation() + case .manus: ManusProviderImplementation() case .kimi: KimiProviderImplementation() case .kilo: KiloProviderImplementation() case .kiro: KiroProviderImplementation() @@ -32,14 +34,28 @@ enum ProviderImplementationRegistry { case .augment: AugmentProviderImplementation() case .jetbrains: JetBrainsProviderImplementation() case .kimik2: KimiK2ProviderImplementation() + case .moonshot: MoonshotProviderImplementation() case .amp: AmpProviderImplementation() case .ollama: OllamaProviderImplementation() case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() + case .elevenlabs: ElevenLabsProviderImplementation() case .warp: WarpProviderImplementation() + case .windsurf: WindsurfProviderImplementation() case .perplexity: PerplexityProviderImplementation() + case .mimo: MiMoProviderImplementation() + case .doubao: DoubaoProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() + case .deepseek: DeepSeekProviderImplementation() + case .codebuff: CodebuffProviderImplementation() + case .crof: CrofProviderImplementation() + case .venice: VeniceProviderImplementation() + case .commandcode: CommandCodeProviderImplementation() + case .stepfun: StepFunProviderImplementation() + case .bedrock: BedrockProviderImplementation() + case .grok: GrokProviderImplementation() + case .deepgram: DeepgramProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 7b408d8da..0764e0325 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -25,6 +25,33 @@ struct ProviderSettingsContext { let setLastAppActiveRunAt: (String, Date?) -> Void let requestConfirmation: (ProviderSettingsConfirmation) -> Void + let runLoginFlow: () async -> Void + + init( + provider: UsageProvider, + settings: SettingsStore, + store: UsageStore, + boolBinding: @escaping (ReferenceWritableKeyPath) -> Binding, + stringBinding: @escaping (ReferenceWritableKeyPath) -> Binding, + statusText: @escaping (String) -> String?, + setStatusText: @escaping (String, String?) -> Void, + lastAppActiveRunAt: @escaping (String) -> Date?, + setLastAppActiveRunAt: @escaping (String, Date?) -> Void, + requestConfirmation: @escaping (ProviderSettingsConfirmation) -> Void, + runLoginFlow: @escaping () async -> Void = {}) + { + self.provider = provider + self.settings = settings + self.store = store + self.boolBinding = boolBinding + self.stringBinding = stringBinding + self.statusText = statusText + self.setStatusText = setStatusText + self.lastAppActiveRunAt = lastAppActiveRunAt + self.setLastAppActiveRunAt = setLastAppActiveRunAt + self.requestConfirmation = requestConfirmation + self.runLoginFlow = runLoginFlow + } } /// Shared confirmation alert descriptor. @@ -85,6 +112,16 @@ struct ProviderSettingsFieldDescriptor: Identifiable { let onActivate: (() -> Void)? } +/// Shared action row descriptor rendered in the Providers settings pane. +@MainActor +struct ProviderSettingsActionsDescriptor: Identifiable { + let id: String + let title: String + let subtitle: String + let actions: [ProviderSettingsActionDescriptor] + let isVisible: (() -> Bool)? +} + /// Shared token account descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsTokenAccountsDescriptor: Identifiable { @@ -97,12 +134,43 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let accounts: () -> [ProviderTokenAccount] let activeIndex: () -> Int let setActiveIndex: (Int) -> Void - let addAccount: (_ label: String, _ token: String) -> Void + let showsOrganizationField: Bool + let addAccount: (_ label: String, _ token: String, _ organizationID: String?) -> Void let removeAccount: (_ accountID: UUID) -> Void + let primaryAddActionTitle: String? + let primaryAddAction: (() async -> Void)? let openConfigFile: () -> Void let reloadFromDisk: () -> Void } +/// Shared organizations descriptor rendered in the Providers settings pane. +/// +/// Used by providers that let the user opt in to additional account scopes +/// (e.g. Kilo organizations) shown alongside the personal account. +@MainActor +struct ProviderSettingsOrganizationsDescriptor: Identifiable { + struct Entry: Identifiable { + let id: String + let title: String + let subtitle: String? + let isEnabled: Bool + let isLocked: Bool + } + + struct RefreshOutcome { + let success: Bool + let errorMessage: String? + } + + let id: String + let title: String + let subtitle: String? + let entries: () -> [Entry] + let onToggle: (String, Bool) -> Void + let onRefresh: () async -> RefreshOutcome + let canRefresh: () -> Bool +} + /// Shared picker descriptor rendered in the Providers settings pane. @MainActor struct ProviderSettingsPickerDescriptor: Identifiable { diff --git a/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift new file mode 100644 index 000000000..dec72a8c8 --- /dev/null +++ b/Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift @@ -0,0 +1,161 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct StepFunProviderImplementation: ProviderImplementation { + let id: UsageProvider = .stepfun + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.stepfunCookieSource + _ = settings.stepfunUsername + _ = settings.stepfunPassword + _ = settings.stepfunToken + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + // Available if any auth method is configured + if !context.settings.stepfunUsername.isEmpty, !context.settings.stepfunPassword.isEmpty { + return true + } + if context.settings.stepfunCookieSource == .manual, !context.settings.stepfunToken.isEmpty { + return true + } + if CookieHeaderCache.load(provider: .stepfun) != nil { + return true + } + if StepFunSettingsReader.username(environment: context.environment) != nil, + StepFunSettingsReader.password(environment: context.environment) != nil + { + return true + } + if StepFunSettingsReader.token(environment: context.environment) != nil { + return true + } + return false + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .stepfun(context.settings.stepfunSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.stepfunCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.stepfunCookieSource != .manual { + settings.stepfunCookieSource = .manual + } + } + + // MARK: - Settings Pickers + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.stepfunCookieSource.rawValue }, + set: { raw in + context.settings.stepfunCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.stepfunCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Uses username + password to login and obtain an Oasis-Token automatically.", + manual: "Manually paste an Oasis-Token from a browser session.", + off: "StepFun authentication is disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "stepfun-cookie-source", + title: "Auth source", + subtitle: "Uses username + password to login and obtain an Oasis-Token automatically.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.load(provider: .stepfun) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + // MARK: - Settings Fields + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + // Auto mode: show username + password fields + let autoFields: [ProviderSettingsFieldDescriptor] = [ + ProviderSettingsFieldDescriptor( + id: "stepfun-username", + title: "Username", + subtitle: "StepFun platform account (phone number or email).", + kind: .plain, + placeholder: "user@example.com", + binding: context.stringBinding(\.stepfunUsername), + actions: [], + isVisible: { context.settings.stepfunCookieSource != .manual }, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "stepfun-password", + title: "Password", + subtitle: "Your StepFun platform password. Used to login and obtain a session token.", + kind: .secure, + placeholder: "Password", + binding: context.stringBinding(\.stepfunPassword), + actions: [], + isVisible: { context.settings.stepfunCookieSource != .manual }, + onActivate: nil), + ] + + // Manual mode: show token field + let manualFields: [ProviderSettingsFieldDescriptor] = [ + ProviderSettingsFieldDescriptor( + id: "stepfun-token", + title: "Oasis-Token", + subtitle: "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com.", + kind: .secure, + placeholder: "Oasis-Token=…", + binding: context.stringBinding(\.stepfunToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "stepfun-open-platform", + title: "Open StepFun Platform", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.stepfun.com/plan-usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.stepfunCookieSource == .manual }, + onActivate: nil), + ] + + return autoFields + manualFields + } +} diff --git a/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift new file mode 100644 index 000000000..d1a533e90 --- /dev/null +++ b/Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + /// Username for StepFun login — stored in the apiKey config field. + var stepfunUsername: String { + get { self.configSnapshot.providerConfig(for: .stepfun)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .stepfun) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange( + provider: .stepfun, + field: "username", + value: newValue.isEmpty ? "(cleared)" : "(updated)") + } + } + + /// Password for StepFun login — stored in the cookieHeader config field (secure storage). + var stepfunPassword: String { + get { self.configSnapshot.providerConfig(for: .stepfun)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .stepfun) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .stepfun, field: "password", value: newValue) + } + } + + /// Manual Oasis-Token — stored in the region config field (repurposed for token). + var stepfunToken: String { + get { self.configSnapshot.providerConfig(for: .stepfun)?.region ?? "" } + set { + self.updateProviderConfig(provider: .stepfun) { entry in + entry.region = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .stepfun, field: "token", value: newValue) + } + } + + var stepfunCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .stepfun, fallback: .auto) } + set { + self.updateProviderConfig(provider: .stepfun) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .stepfun, field: "cookieSource", value: newValue.rawValue) + } + } + + func stepfunSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .StepFunProviderSettings + { + ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: self.stepfunSnapshotCookieSource(tokenOverride: tokenOverride), + manualToken: self.stepfunSnapshotToken(tokenOverride: tokenOverride), + username: self.stepfunUsername, + password: self.stepfunPassword) + } + + private func stepfunSnapshotToken(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.stepfunToken + guard let support = TokenAccountSupportCatalog.support(for: .stepfun), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .stepfun, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func stepfunSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.stepfunCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .stepfun), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .stepfun).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift b/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift new file mode 100644 index 000000000..d16b84b74 --- /dev/null +++ b/Sources/CodexBar/Providers/Venice/VeniceProviderImplementation.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct VeniceProviderImplementation: ProviderImplementation { + let id: UsageProvider = .venice + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_: SettingsStore) {} + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if VeniceSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + return !context.settings.tokenAccounts(for: .venice).isEmpty + } + + @MainActor + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift index 1f8fe5418..2741f091d 100644 --- a/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift +++ b/Sources/CodexBar/Providers/VertexAI/VertexAILoginFlow.swift @@ -7,20 +7,11 @@ extension StatusItemController { func runVertexAILoginFlow() async { // Show alert with instructions let alert = NSAlert() - alert.messageText = "Vertex AI Login" - alert.informativeText = """ - To use Vertex AI tracking, you need to authenticate with Google Cloud. - - 1. Open Terminal - 2. Run: gcloud auth application-default login - 3. Follow the browser prompts to sign in - 4. Set your project: gcloud config set project PROJECT_ID - - Would you like to open Terminal now? - """ + alert.messageText = L("Vertex AI Login") + alert.informativeText = L("vertex_ai_login_instructions") alert.alertStyle = .informational - alert.addButton(withTitle: "Open Terminal") - alert.addButton(withTitle: "Cancel") + alert.addButton(withTitle: L("Open Terminal")) + alert.addButton(withTitle: L("Cancel")) let response = alert.runModal() diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift new file mode 100644 index 000000000..38d290663 --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift @@ -0,0 +1,106 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct WindsurfProviderImplementation: ProviderImplementation { + let id: UsageProvider = .windsurf + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.windsurfUsageDataSource + _ = settings.windsurfCookieSource + _ = settings.windsurfCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .windsurf(context.settings.windsurfSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + switch context.settings.windsurfUsageDataSource { + case .auto: .auto + case .web: .web + case .cli: .cli + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + // Usage source picker + let usageBinding = Binding( + get: { context.settings.windsurfUsageDataSource.rawValue }, + set: { raw in + context.settings.windsurfUsageDataSource = WindsurfUsageDataSource(rawValue: raw) ?? .auto + }) + let usageOptions = WindsurfUsageDataSource.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) + } + + // Cookie source picker + let cookieBinding = Binding( + get: { context.settings.windsurfCookieSource.rawValue }, + set: { raw in + context.settings.windsurfCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.windsurfCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports Windsurf session data from Chromium browser localStorage.", + manual: "Paste the Windsurf session JSON bundle from localStorage.", + off: "Windsurf web API access is disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "windsurf-usage-source", + title: "Usage source", + subtitle: "Auto falls back to the next source if the preferred one fails.", + binding: usageBinding, + options: usageOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard context.settings.windsurfUsageDataSource == .auto else { return nil } + let label = context.store.sourceLabel(for: .windsurf) + return label == "auto" ? nil : label + }), + ProviderSettingsPickerDescriptor( + id: "windsurf-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports Windsurf session data from Chromium browser localStorage.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "windsurf-cookie-header", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Windsurf session JSON bundle", + binding: context.stringBinding(\.windsurfCookieHeader), + actions: [], + isVisible: { + context.settings.windsurfCookieSource == .manual + }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift new file mode 100644 index 000000000..683e97c7e --- /dev/null +++ b/Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift @@ -0,0 +1,93 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var windsurfUsageDataSource: WindsurfUsageDataSource { + get { + let source = self.configSnapshot.providerConfig(for: .windsurf)?.source + return Self.windsurfUsageDataSource(from: source) + } + set { + let source: ProviderSourceMode? = switch newValue { + case .auto: .auto + case .web: .web + case .cli: .cli + } + self.updateProviderConfig(provider: .windsurf) { entry in + entry.source = source + } + self.logProviderModeChange(provider: .windsurf, field: "usageSource", value: newValue.rawValue) + } + } + + var windsurfCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .windsurf, fallback: .auto) } + set { + self.updateProviderConfig(provider: .windsurf) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .windsurf, field: "cookieSource", value: newValue.rawValue) + } + } + + var windsurfCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .windsurf)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .windsurf) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .windsurf, field: "cookieHeader", value: newValue) + } + } +} + +extension SettingsStore { + func windsurfSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.WindsurfProviderSettings + { + ProviderSettingsSnapshot.WindsurfProviderSettings( + usageDataSource: self.windsurfUsageDataSource, + cookieSource: self.windsurfSnapshotCookieSource(tokenOverride: tokenOverride), + manualCookieHeader: self.windsurfSnapshotCookieHeader(tokenOverride: tokenOverride)) + } + + private static func windsurfUsageDataSource(from source: ProviderSourceMode?) -> WindsurfUsageDataSource { + guard let source else { return .auto } + switch source { + case .auto, .oauth, .api: + return .auto + case .web: + return .web + case .cli: + return .cli + } + } + + private func windsurfSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { + let fallback = self.windsurfCookieHeader + guard let support = TokenAccountSupportCatalog.support(for: .windsurf), + case .cookieHeader = support.injection + else { + return fallback + } + guard let account = ProviderTokenAccountSelection.selectedAccount( + provider: .windsurf, + settings: self, + override: tokenOverride) + else { + return fallback + } + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + + private func windsurfSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { + let fallback = self.windsurfCookieSource + guard let support = TokenAccountSupportCatalog.support(for: .windsurf), + support.requiresManualCookieSource + else { + return fallback + } + if self.tokenAccounts(for: .windsurf).isEmpty { return fallback } + return .manual + } +} diff --git a/Sources/CodexBar/QuotaWarningSettingsViews.swift b/Sources/CodexBar/QuotaWarningSettingsViews.swift new file mode 100644 index 000000000..ce37b40f1 --- /dev/null +++ b/Sources/CodexBar/QuotaWarningSettingsViews.swift @@ -0,0 +1,252 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct GlobalQuotaWarningSettingsView: View { + @Bindable var settings: SettingsStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 16) { + Toggle(isOn: Binding( + get: { self.settings.quotaWarningWindowEnabled(.session) }, + set: { self.settings.setQuotaWarningWindowEnabled(.session, enabled: $0) })) + { + Text(L("quota_warning_session_capitalized")) + .font(.footnote) + } + .toggleStyle(.checkbox) + + Toggle(isOn: Binding( + get: { self.settings.quotaWarningWindowEnabled(.weekly) }, + set: { self.settings.setQuotaWarningWindowEnabled(.weekly, enabled: $0) })) + { + Text(L("quota_warning_weekly_capitalized")) + .font(.footnote) + } + .toggleStyle(.checkbox) + } + + self.windowThresholdField(.session) + self.windowThresholdField(.weekly) + + Toggle(isOn: self.$settings.quotaWarningSoundEnabled) { + Text(L("quota_warning_sound")) + .font(.footnote) + } + .toggleStyle(.checkbox) + } + .padding(.leading, 20) + } + + private func windowThresholdField(_ window: QuotaWarningWindow) -> some View { + QuotaWarningThresholdField( + title: String(format: L("quota_warning_window_warn_at"), window.localizedCapitalizedDisplayName), + subtitle: L("quota_warning_global_threshold_subtitle"), + thresholds: { self.settings.quotaWarningThresholds(window) }, + setThresholds: { self.settings.setQuotaWarningThresholds(window, thresholds: $0) }) + .disabled(!self.settings.quotaWarningWindowEnabled(window)) + .opacity(!self.settings.quotaWarningWindowEnabled(window) ? 0.55 : 1) + } +} + +@MainActor +struct ProviderQuotaWarningSettingsView: View { + let provider: UsageProvider + @Bindable var settings: SettingsStore + + var body: some View { + ProviderSettingsSection(title: L("quota_warnings_title")) { + Text(L("quota_warning_provider_inherits")) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + self.windowRow(.session) + self.windowRow(.weekly) + } + } + + private func windowRow(_ window: QuotaWarningWindow) -> some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding( + get: { self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) }, + set: { isOn in + if isOn { + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: self.settings.quotaWarningThresholds(window), + enabled: self.settings.quotaWarningWindowEnabled(window)) + } else { + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: nil, + enabled: nil) + } + })) { + Text(String(format: L("quota_warning_customize_thresholds"), window.localizedDisplayName)) + .font(.subheadline.weight(.semibold)) + } + .toggleStyle(.checkbox) + + if self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) { + Toggle(isOn: Binding( + get: { self.settings.quotaWarningEnabled(provider: self.provider, window: window) }, + set: { + self.settings.setQuotaWarningWindowEnabled( + provider: self.provider, + window: window, + enabled: $0) + })) { + Text(String(format: L("quota_warning_enable_warnings"), window.localizedDisplayName)) + .font(.footnote) + } + .toggleStyle(.checkbox) + .padding(.leading, 20) + + if self.settings.quotaWarningEnabled(provider: self.provider, window: window) { + QuotaWarningThresholdField( + title: String( + format: L("quota_warning_window_warn_at"), + window.localizedCapitalizedDisplayName), + subtitle: "", + thresholds: { + self.settings.resolvedQuotaWarningThresholds(provider: self.provider, window: window) + }, + setThresholds: { + self.settings.setQuotaWarningThresholds( + provider: self.provider, + window: window, + thresholds: $0) + }) + .padding(.leading, 20) + } else { + Text(L("quota_warning_off")) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } else { + Text(String(format: L("quota_warning_inherited"), Self.thresholdText( + self.settings.quotaWarningThresholds(window), + enabled: self.settings.quotaWarningWindowEnabled(window)))) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } + } + + private static func thresholdText(_ thresholds: [Int], enabled: Bool) -> String { + guard enabled else { return L("quota_warning_off") } + let text = QuotaWarningThresholds.active(thresholds).map { "\($0)%" }.joined(separator: ", ") + return text.isEmpty ? L("quota_warning_depleted_only") : text + } +} + +extension QuotaWarningWindow { + fileprivate var localizedDisplayName: String { + switch self { + case .session: L("quota_warning_session") + case .weekly: L("quota_warning_weekly") + } + } + + fileprivate var localizedCapitalizedDisplayName: String { + switch self { + case .session: L("quota_warning_session_capitalized") + case .weekly: L("quota_warning_weekly_capitalized") + } + } +} + +@MainActor +private struct QuotaWarningThresholdField: View { + let title: String + let subtitle: String + let thresholds: () -> [Int] + let setThresholds: ([Int]) -> Void + + @State private var upperText: String = "" + @State private var lowerText: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(self.title) + .font(.footnote.weight(.semibold)) + .frame(width: 110, alignment: .leading) + + Text(L("quota_warning_upper")) + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("50", text: self.$upperText) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .frame(width: 56) + .onChange(of: self.upperText) { _, value in + self.upperText = Self.filteredIntegerText(value) + } + .onSubmit { self.commit() } + + Text(L("quota_warning_lower")) + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("20", text: self.$lowerText) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .frame(width: 56) + .onChange(of: self.lowerText) { _, value in + self.lowerText = Self.filteredIntegerText(value) + } + .onSubmit { self.commit() } + + Button(L("apply")) { self.commit() } + .controlSize(.small) + } + + if !self.subtitle.isEmpty { + Text(self.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { self.updateText(from: self.thresholds()) } + .onChange(of: self.thresholds()) { _, value in + self.updateText(from: value) + } + } + + private func commit() { + let sanitized = QuotaWarningThresholds.resolved( + upper: Self.integer(from: self.upperText), + lower: Self.integer(from: self.lowerText)) + self.updateText(from: sanitized) + self.setThresholds(sanitized) + } + + private func updateText(from thresholds: [Int]) { + let pair = Self.pair(from: thresholds) + self.upperText = pair.upper.map(String.init) ?? "" + self.lowerText = pair.lower.map(String.init) ?? "" + } + + private static func pair(from thresholds: [Int]) -> (upper: Int?, lower: Int?) { + let sanitized = QuotaWarningThresholds.sanitized(thresholds) + return (sanitized.first, sanitized.dropFirst().first) + } + + private static func integer(from text: String) -> Int? { + guard !text.isEmpty else { return nil } + return Int(text) + } + + private static func filteredIntegerText(_ text: String) -> String { + String(text.filter(\.isNumber).prefix(2)) + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg b/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg new file mode 100644 index 000000000..01dd2f59f --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg b/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg new file mode 100644 index 000000000..6d5f9e455 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-codebuff.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-commandcode.svg b/Sources/CodexBar/Resources/ProviderIcon-commandcode.svg new file mode 100644 index 000000000..eb48c4358 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-commandcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-crof.svg b/Sources/CodexBar/Resources/ProviderIcon-crof.svg new file mode 100644 index 000000000..fdde018b8 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-crof.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-deepgram.svg b/Sources/CodexBar/Resources/ProviderIcon-deepgram.svg new file mode 100644 index 000000000..a72ab7db1 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-deepgram.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg new file mode 100644 index 000000000..72020f9ad --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg new file mode 100644 index 000000000..9c20430a1 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg @@ -0,0 +1 @@ +Doubao diff --git a/Sources/CodexBar/Resources/ProviderIcon-elevenlabs.svg b/Sources/CodexBar/Resources/ProviderIcon-elevenlabs.svg new file mode 100644 index 000000000..338e42b28 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-elevenlabs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-grok.svg b/Sources/CodexBar/Resources/ProviderIcon-grok.svg new file mode 100644 index 000000000..876acc82c --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg index 03dee9305..77cba2eac 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg @@ -1 +1 @@ -Kimi +Kimi diff --git a/Sources/CodexBar/Resources/ProviderIcon-manus.svg b/Sources/CodexBar/Resources/ProviderIcon-manus.svg new file mode 100644 index 000000000..fcfd81daf --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-manus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-mimo.svg b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg new file mode 100644 index 000000000..50b1b8e3e --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-mimo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg b/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg new file mode 100644 index 000000000..915c71d2c --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-stepfun.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-venice.svg b/Sources/CodexBar/Resources/ProviderIcon-venice.svg new file mode 100644 index 000000000..31408ddf7 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-venice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg new file mode 100644 index 000000000..3bc424679 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-windsurf.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..16e39d5b4 --- /dev/null +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -0,0 +1,636 @@ +/* English localization for CodexBar (base/fallback) */ + +" providers" = " providers"; +"(System)" = "(System)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; +"API key" = "API key"; +"API region" = "API region"; +"API token" = "API token"; +"API tokens" = "API tokens"; +"About" = "About"; +"Account" = "Account"; +"Accounts" = "Accounts"; +"Accounts subtitle" = "Accounts subtitle"; +"Active" = "Active"; +"Add" = "Add"; +"Add Workspace" = "Add Workspace"; +"Advanced" = "Advanced"; +"All" = "All"; +"Always allow prompts" = "Always allow prompts"; +"Animation pattern" = "Animation pattern"; +"Antigravity login is managed in the app" = "Antigravity login is managed in the app"; +"Applies only to the Security.framework OAuth keychain reader." = "Applies only to the Security.framework OAuth keychain reader."; +"Auto falls back to the next source if the preferred one fails." = "Auto falls back to the next source if the preferred one fails."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto uses API first, then falls back to CLI on auth failures."; +"Auto-detect" = "Auto-detect"; +"Auto-refresh is off; use the menu's Refresh command." = "Auto-refresh is off; use the menu's Refresh command."; +"Auto-refresh: hourly · Timeout: 10m" = "Auto-refresh: hourly · Timeout: 10m"; +"Automatic" = "Automatic"; +"Automatic imports browser cookies and WorkOS tokens." = "Automatic imports browser cookies and WorkOS tokens."; +"Automatic imports browser cookies and local storage tokens." = "Automatic imports browser cookies and local storage tokens."; +"Automatic imports browser cookies for dashboard extras." = "Automatic imports browser cookies for dashboard extras."; +"Automatic imports browser cookies for the web API." = "Automatic imports browser cookies for the web API."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Automatic imports browser cookies from Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Automatic imports browser cookies from admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Automatic imports browser cookies from opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Automatic imports browser cookies or stored sessions."; +"Automatic imports browser cookies." = "Automatic imports browser cookies."; +"Automatically imports browser session cookie." = "Automatically imports browser session cookie."; +"Automatically opens CodexBar when you start your Mac." = "Automatically opens CodexBar when you start your Mac."; +"Automation" = "Automation"; +"Average (\\(label1) + \\(label2))" = "Average (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Avoid Keychain prompts"; +"Balance" = "Balance"; +"Battery Saver" = "Battery Saver"; +"Bordered" = "Bordered"; +"Build" = "Build"; +"Built \\(buildTimestamp)" = "Built \\(buildTimestamp)"; +"Buy Credits..." = "Buy Credits..."; +"Buy Credits…" = "Buy Credits…"; +"CLI paths" = "CLI paths"; +"CLI sessions" = "CLI sessions"; +"Caches" = "Caches"; +"Cancel" = "Cancel"; +"Check for Updates…" = "Check for Updates…"; +"Check for updates automatically" = "Check for updates automatically"; +"Check if you like your agents having some fun up there." = "Check if you like your agents having some fun up there."; +"Check provider status" = "Check provider status"; +"Choose Codex workspace" = "Choose Codex workspace"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Choose the MiniMax host (global .io or China mainland .com)."; +"Choose up to " = "Choose up to "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Choose up to \\(Self.maxOverviewProviders) providers"; +"Choose up to \\(count) providers" = "Choose up to \\(count) providers"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"Choose which Codex account CodexBar should follow." = "Choose which Codex account CodexBar should follow."; +"Choose which window drives the menu bar percent." = "Choose which window drives the menu bar percent."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI not found"; +"Claude binary" = "Claude binary"; +"Claude cookies" = "Claude cookies"; +"Claude login failed" = "Claude login failed"; +"Claude login timed out" = "Claude login timed out"; +"Close" = "Close"; +"Code review" = "Code review"; +"Codex CLI not found" = "Codex CLI not found"; +"Codex account login already running" = "Codex account login already running"; +"Codex binary" = "Codex binary"; +"Codex login failed" = "Codex login failed"; +"Codex login timed out" = "Codex login timed out"; +"CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar can't show its menu bar icon"; +"CodexBar could not read managed account storage. " = "CodexBar could not read managed account storage. "; +"Configure…" = "Configure…"; +"Connected" = "Connected"; +"Controls how much detail is logged." = "Controls how much detail is logged."; +"Cookie header" = "Cookie header"; +"Cookie source" = "Cookie source"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Cost"; +"Could not add Codex account" = "Could not add Codex account"; +"Could not open Terminal for Gemini" = "Could not open Terminal for Gemini"; +"Could not start claude /login" = "Could not start claude /login"; +"Could not start codex login" = "Could not start codex login"; +"Could not switch system account" = "Could not switch system account"; +"Credits" = "Credits"; +"Credits history" = "Credits history"; +"Cursor login failed" = "Cursor login failed"; +"Custom" = "Custom"; +"Custom Path" = "Custom Path"; +"Daily Routines" = "Daily Routines"; +"Debug" = "Debug"; +"Default" = "Default"; +"Designs" = "Designs"; +"Disable Keychain access" = "Disable Keychain access"; +"Disabled" = "Disabled"; +"Dismiss" = "Dismiss"; +"Disconnected" = "Disconnected"; +"Display" = "Display"; +"Display mode" = "Display mode"; +"Display reset times as absolute clock values instead of countdowns." = "Display reset times as absolute clock values instead of countdowns."; +"Done" = "Done"; +"Effective PATH" = "Effective PATH"; +"Email" = "Email"; +"Enable Merge Icons to configure Overview tab providers." = "Enable Merge Icons to configure Overview tab providers."; +"Enable file logging" = "Enable file logging"; +"Enabled" = "Enabled"; +"Error" = "Error"; +"Error simulation" = "Error simulation"; +"Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; +"Failed" = "Failed"; +"False" = "False"; +"Fetch strategy attempts" = "Fetch strategy attempts"; +"Fetching" = "Fetching"; +"Field" = "Field"; +"Field subtitle" = "Field subtitle"; +"Finish the current managed account change before switching the system account." = "Finish the current managed account change before switching the system account."; +"Force animation on next refresh" = "Force animation on next refresh"; +"Gateway region" = "Gateway region"; +"Gemini CLI not found" = "Gemini CLI not found"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, surfacing incidents in the icon and menu."; +"General" = "General"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot Login"; +"GitHub Login" = "GitHub Login"; +"Hide details" = "Hide details"; +"Hide personal information" = "Hide personal information"; +"Historical tracking" = "Historical tracking"; +"How often CodexBar polls providers in the background." = "How often CodexBar polls providers in the background."; +"Inactive" = "Inactive"; +"Install CLI" = "Install CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Install the Codex CLI (npm i -g @openai/codex) and try again."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again."; +"JetBrains AI is ready" = "JetBrains AI is ready"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Keep CLI sessions alive"; +"Keyboard shortcut" = "Keyboard shortcut"; +"Keychain access" = "Keychain access"; +"Keychain prompt policy" = "Keychain prompt policy"; +"Last \\(name) fetch failed:" = "Last \\(name) fetch failed:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:"; +"Last attempt" = "Last attempt"; +"Link" = "Link"; +"Loading animations" = "Loading animations"; +"Loading…" = "Loading…"; +"Local" = "Local"; +"Logging" = "Logging"; +"Login failed" = "Login failed"; +"Login shell PATH (startup capture)" = "Login shell PATH (startup capture)"; +"Login timed out" = "Login timed out"; +"MCP details" = "MCP details"; +"Managed Codex accounts unavailable" = "Managed Codex accounts unavailable"; +"Managed account storage is unreadable. Live account access is still available, " = "Managed account storage is unreadable. Live account access is still available, "; +"Manual" = "Manual"; +"May your tokens never run out—keep agent limits in view." = "May your tokens never run out—keep agent limits in view."; +"Menu bar" = "Menu bar"; +"Menu bar auto-shows the provider closest to its rate limit." = "Menu bar auto-shows the provider closest to its rate limit."; +"Menu bar metric" = "Menu bar metric"; +"Menu bar shows percent" = "Menu bar shows percent"; +"Menu content" = "Menu content"; +"Merge Icons" = "Merge Icons"; +"Never prompt" = "Never prompt"; +"No" = "No"; +"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."; +"No providers selected" = "No providers selected"; +"No token accounts yet." = "No token accounts yet."; +"No usage breakdown data." = "No usage breakdown data."; +"None" = "None"; +"Notifications" = "Notifications"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Notifies when the 5-hour session quota hits 0% and when it becomes "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Obscure email addresses in the menu bar and menu UI."; +"Off" = "Off"; +"Off-peak" = "Off-peak"; +"Off-peak · peak in \\(self.formatDuration(minutes: minutes))" = "Off-peak · peak in \\(self.formatDuration(minutes: minutes))"; +"Offline" = "Offline"; +"On" = "On"; +"Online" = "Online"; +"Only on user action" = "Only on user action"; +"Open" = "Open"; +"Open API Keys" = "Open API Keys"; +"Open Amp Settings" = "Open Amp Settings"; +"Open Antigravity to sign in, then refresh CodexBar." = "Open Antigravity to sign in, then refresh CodexBar."; +"Open Browser" = "Open Browser"; +"Open Coding Plan" = "Open Coding Plan"; +"Open Console" = "Open Console"; +"Open Dashboard" = "Open Dashboard"; +"Open Mistral Admin" = "Open Mistral Admin"; +"Open Menu Bar Settings" = "Open Menu Bar Settings"; +"Open Ollama Settings" = "Open Ollama Settings"; +"Open Terminal" = "Open Terminal"; +"Open Usage Page" = "Open Usage Page"; +"Open Warp API Key Guide" = "Open Warp API Key Guide"; +"Open menu" = "Open menu"; +"Open token file" = "Open token file"; +"OpenAI cookies" = "OpenAI cookies"; +"OpenAI web extras" = "OpenAI web extras"; +"Option A" = "Option A"; +"Option B" = "Option B"; +"Optional override if workspace lookup fails." = "Optional override if workspace lookup fails."; +"Options" = "Options"; +"Override auto-detection with a custom IDE base path" = "Override auto-detection with a custom IDE base path"; +"Overview" = "Overview"; +"Overview rows always follow provider order." = "Overview rows always follow provider order."; +"Overview tab providers" = "Overview tab providers"; +"Paste API key…" = "Paste API key…"; +"Paste API token…" = "Paste API token…"; +"Paste key…" = "Paste key…"; +"Paste sessionKey or OAuth token…" = "Paste sessionKey or OAuth token…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Paste the Cookie header from a request to admin.mistral.ai. "; +"Paste token…" = "Paste token…"; +"Peak · ends in \\(self.formatDuration(minutes: remaining))" = "Peak · ends in \\(self.formatDuration(minutes: remaining))"; +"Personal" = "Personal"; +"Picker" = "Picker"; +"Picker subtitle" = "Picker subtitle"; +"Placeholder" = "Placeholder"; +"Plan" = "Plan"; +"Play full-screen confetti when weekly usage resets." = "Play full-screen confetti when weekly usage resets."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Polls OpenAI/Claude status pages and Google Workspace for "; +"Prevents any Keychain access while enabled." = "Prevents any Keychain access while enabled."; +"Primary (API key limit)" = "Primary (API key limit)"; +"Primary (\\(label))" = "Primary (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primary (\\(metadata.sessionLabel))"; +"Probe logs" = "Probe logs"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Progress bars fill as you consume quota (instead of showing remaining)."; +"Provider" = "Provider"; +"Providers" = "Providers"; +"Quit CodexBar" = "Quit CodexBar"; +"Random (default)" = "Random (default)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"Refresh" = "Refresh"; +"Refresh cadence" = "Refresh cadence"; +"Remote" = "Remote"; +"Remove" = "Remove"; +"Remove Codex account?" = "Remove Codex account?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Remove \\(email) from CodexBar? Its managed Codex home will be deleted."; +"Remove selected account" = "Remove selected account"; +"Replace critter bars with provider branding icons and a percentage." = "Replace critter bars with provider branding icons and a percentage."; +"Replay selected animation" = "Replay selected animation"; +"Requires authentication via GitHub Device Flow." = "Requires authentication via GitHub Device Flow."; +"Resets: \\(reset)" = "Resets: \\(reset)"; +"Rolling five-hour limit" = "Rolling five-hour limit"; +"Search hourly" = "Search hourly"; +"Secondary (\\(label))" = "Secondary (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secondary (\\(metadata.weeklyLabel))"; +"Select a provider" = "Select a provider"; +"Select the IDE to monitor" = "Select the IDE to monitor"; +"Session quota notifications" = "Session quota notifications"; +"Session tokens" = "Session tokens"; +"Settings" = "Settings"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Show Codex Credits and Claude Extra usage sections in the menu."; +"Show Debug Settings" = "Show Debug Settings"; +"Show all token accounts" = "Show all token accounts"; +"Show cost summary" = "Show cost summary"; +"Show credits + extra usage" = "Show credits + extra usage"; +"Show details" = "Show details"; +"Show most-used provider" = "Show most-used provider"; +"Show peak hours indicator" = "Show peak hours indicator"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"Show reset time as clock" = "Show reset time as clock"; +"Show usage as used" = "Show usage as used"; +"Show whether Claude is in peak usage hours." = "Show whether Claude is in peak usage hours."; +"Sign in via button below" = "Sign in via button below"; +"Skip teardown between probes (debug-only)." = "Skip teardown between probes (debug-only)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"Start at Login" = "Start at Login"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Store Claude sessionKey cookies or OAuth access tokens."; +"Store multiple Abacus AI Cookie headers." = "Store multiple Abacus AI Cookie headers."; +"Store multiple Augment Cookie headers." = "Store multiple Augment Cookie headers."; +"Store multiple Cursor Cookie headers." = "Store multiple Cursor Cookie headers."; +"Store multiple Factory Cookie headers." = "Store multiple Factory Cookie headers."; +"Store multiple MiniMax Cookie headers." = "Store multiple MiniMax Cookie headers."; +"Store multiple Mistral Cookie headers." = "Store multiple Mistral Cookie headers."; +"Store multiple Ollama Cookie headers." = "Store multiple Ollama Cookie headers."; +"Store multiple OpenCode Cookie headers." = "Store multiple OpenCode Cookie headers."; +"Store multiple OpenCode Go Cookie headers." = "Store multiple OpenCode Go Cookie headers."; +"Stored in the CodexBar config file." = "Stored in the CodexBar config file."; +"Stored in ~/.codexbar/config.json. " = "Stored in ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Stored in ~/.codexbar/config.json. Paste your MiniMax API key."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Stores local Codex usage history (8 weeks) to personalize Pace predictions."; +"Subscription Utilization" = "Subscription Utilization"; +"Surprise me" = "Surprise me"; +"Switcher shows icons" = "Switcher shows icons"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"System" = "System"; +"Temporarily shows the loading animation after the next refresh." = "Temporarily shows the loading animation after the next refresh."; +"Tertiary (\\(label))" = "Tertiary (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiary (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "The default Codex account on this Mac."; +"Toggle" = "Toggle"; +"Toggle subtitle" = "Toggle subtitle"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Trigger the menu bar menu from anywhere."; +"True" = "True"; +"Twitter" = "Twitter"; +"Unsupported" = "Unsupported"; +"Update Channel" = "Update Channel"; +"Updated" = "Updated"; +"Updates unavailable in this build." = "Updates unavailable in this build."; +"Usage" = "Usage"; +"Usage breakdown" = "Usage breakdown"; +"Usage history (30 days)" = "Usage history (30 days)"; +"Usage source" = "Usage source"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Use BigModel for the China mainland endpoints (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Use a single menu bar icon with a provider switcher."; +"Use international or China mainland console gateways for quota fetches." = "Use international or China mainland console gateways for quota fetches."; +"Version" = "Version"; +"Version \\(self.versionString)" = "Version \\(self.versionString)"; +"Version \\(version)" = "Version \\(version)"; +"Version \\(versionString)" = "Version \\(versionString)"; +"Vertex AI Login" = "Vertex AI Login"; +"Wait for the current managed Codex login to finish before adding another account." = "Wait for the current managed Codex login to finish before adding another account."; +"Waiting for Authentication..." = "Waiting for Authentication..."; +"Website" = "Website"; +"Weekly limit confetti" = "Weekly limit confetti"; +"Weekly token limit" = "Weekly token limit"; +"Weekly usage" = "Weekly usage"; +"Weekly usage unavailable for this account." = "Weekly usage unavailable for this account."; +"Window: \\(window)" = "Window: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Write logs to \\(self.fileLogPath) for debugging."; +"Yes" = "Yes"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): fetching…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): last attempt \\(when)"; +"\\(name): no data yet" = "\\(name): no data yet"; +"\\(name): unsupported" = "\\(name): unsupported"; +"all browsers" = "all browsers"; +"available again." = "available again."; +"built_format" = "Built %@"; +"copilot_complete_in_browser" = "Complete sign in in your browser."; +"copilot_device_code" = "Device code copied to clipboard: %1$@\n\nVerify at: %2$@"; +"copilot_device_code_copied" = "Device code copied."; +"copilot_verify_at" = "Verify at %@"; +"copilot_waiting_text" = "Complete sign in in your browser.\nThis window closes automatically when sign-in completes."; +"copilot_window_closes_auto" = "This window closes automatically when sign-in completes."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: fetching… %2$@"; +"cost_status_last_attempt" = "%1$@: last attempt %2$@"; +"cost_status_no_data" = "%@: no data yet"; +"cost_status_snapshot" = "%1$@: %2$@ · 30d %3$@"; +"cost_status_unsupported" = "%@: unsupported"; +"credits_remaining" = "Credits: %@"; +"cursor_on_demand" = "On-demand: %@"; +"cursor_on_demand_with_limit" = "On-demand: %1$@ / %2$@"; +"extra_usage_format" = "Extra usage: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Detected: %@. Use the AI assistant once to generate quota data, then refresh CodexBar."; +"jetbrains_detected_select" = "Detected: %@. Select your preferred IDE in Settings, then refresh CodexBar."; +"last_fetch_failed_with_provider" = "Last %@ fetch failed:"; +"last_spend" = "Last spend: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Resets: %@"; +"mcp_window" = "Window: %@"; +"metric_average" = "Average (%1$@ + %2$@)"; +"metric_primary" = "Primary (%@)"; +"metric_secondary" = "Secondary (%@)"; +"metric_tertiary" = "Tertiary (%@)"; +"multiple_workspaces_found" = "CodexBar found multiple workspaces for %@. Please choose the workspace to add."; +"off_peak" = "Off-peak"; +"off_peak_peak_in" = "Off-peak · peak in %@"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Choose up to %@ providers"; +"peak_ends_in" = "Peak ends in %@"; +"remove_account_message" = "Remove %@ from CodexBar? Its managed Codex home will be deleted."; +"version_format" = "Version %@"; +"vertex_ai_login_instructions" = "To track Vertex AI usage, authenticate with Google Cloud.\n\n1. Open Terminal\n2. Run: gcloud auth application-default login\n3. Follow the browser prompts to sign in\n4. Set your project: gcloud config set project PROJECT_ID\n\nOpen Terminal now?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT License."; + +/* General Pane */ +"section_system" = "System"; +"section_usage" = "Usage"; +"section_automation" = "Automation"; +"language_title" = "Language"; +"language_subtitle" = "Change the display language. Requires app restart to take full effect."; +"language_system" = "System"; +"language_english" = "English"; +"language_chinese_simplified" = "简体中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"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"; +"show_cost_summary_subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu."; +"cost_auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m"; +"refresh_cadence_title" = "Refresh cadence"; +"refresh_cadence_subtitle" = "How often CodexBar polls providers in the background."; +"manual_refresh_hint" = "Auto-refresh is off; use the menu's Refresh command."; +"check_provider_status_title" = "Check provider status"; +"check_provider_status_subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."; +"session_quota_notifications_title" = "Session quota notifications"; +"session_quota_notifications_subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again."; +"quota_warning_notifications_title" = "Quota warning notifications"; +"quota_warning_notifications_subtitle" = "Warns when session or weekly quota remaining crosses configured thresholds."; +"quota_warnings_title" = "Quota warnings"; +"quota_warning_session" = "session"; +"quota_warning_session_capitalized" = "Session"; +"quota_warning_weekly" = "weekly"; +"quota_warning_weekly_capitalized" = "Weekly"; +"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"; +"quota_warning_provider_inherits" = "Uses the global quota warning settings unless a window is customized here."; +"quota_warning_customize_thresholds" = "Customize %@ thresholds"; +"quota_warning_enable_warnings" = "Enable %@ warnings"; +"quota_warning_window_warn_at" = "%@ warn at"; +"quota_warning_off" = "Off"; +"quota_warning_inherited" = "Inherited: %@"; +"quota_warning_depleted_only" = "depleted only"; +"quota_warning_upper" = "Upper"; +"quota_warning_lower" = "Lower"; +"apply" = "Apply"; +"quit_app" = "Quit CodexBar"; + +/* Tab titles */ +"tab_general" = "General"; +"tab_providers" = "Providers"; +"tab_display" = "Display"; +"tab_advanced" = "Advanced"; +"tab_about" = "About"; +"tab_debug" = "Debug"; + +/* Providers Pane */ +"select_a_provider" = "Select a provider"; +"cancel" = "Cancel"; +"last_fetch_failed" = "last fetch failed"; +"usage_not_fetched_yet" = "usage not fetched yet"; +"managed_account_storage_unreadable" = "Managed account storage is unreadable. Live account access is still available, but managed add, re-auth, and remove actions are disabled until the store is recoverable."; +"remove_codex_account_title" = "Remove Codex account?"; +"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."; +"managed_login_missing_email" = "Codex login completed, but no account email was available. Try again after confirming the account is fully signed in."; +"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"; +"menu_bar_metric_subtitle" = "Choose which window drives the menu bar percent."; +"menu_bar_metric_subtitle_deepseek" = "Shows the DeepSeek balance in the menu bar."; +"menu_bar_metric_subtitle_moonshot" = "Shows the Moonshot / Kimi API balance in the menu bar."; +"menu_bar_metric_subtitle_mistral" = "Shows current-month Mistral API spend in the menu bar."; +"menu_bar_metric_subtitle_kimik2" = "Shows Kimi K2 API-key credits in the menu bar."; +"automatic" = "Automatic"; +"primary_api_key_limit" = "Primary (API key limit)"; + +/* Display Pane */ +"section_menu_bar" = "Menu bar"; +"merge_icons_title" = "Merge Icons"; +"merge_icons_subtitle" = "Use a single menu bar icon with a provider switcher."; +"switcher_shows_icons_title" = "Switcher shows icons"; +"switcher_shows_icons_subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line)."; +"show_most_used_provider_title" = "Show most-used provider"; +"show_most_used_provider_subtitle" = "Menu bar auto-shows the provider closest to its rate limit."; +"menu_bar_shows_percent_title" = "Menu bar shows percent"; +"menu_bar_shows_percent_subtitle" = "Replace critter bars with provider branding icons and a percentage."; +"display_mode_title" = "Display mode"; +"display_mode_subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected)."; +"section_menu_content" = "Menu content"; +"show_usage_as_used_title" = "Show usage as used"; +"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."; +"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"; +"show_provider_changelog_links_subtitle" = "Adds release-notes links for supported CLI-backed providers to the menu."; +"show_credits_extra_usage_title" = "Show credits + extra usage"; +"show_credits_extra_usage_subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; +"show_all_token_accounts_title" = "Show all token accounts"; +"show_all_token_accounts_subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar)."; +"multi_account_layout_title" = "Multi-account layout"; +"multi_account_layout_subtitle" = "Choose segmented account switching or stacked account cards."; +"multi_account_layout_segmented" = "Segmented"; +"multi_account_layout_stacked" = "Stacked"; +"overview_tab_providers_title" = "Overview tab providers"; +"configure" = "Configure…"; +"overview_enable_merge_icons_hint" = "Enable Merge Icons to configure Overview tab providers."; +"overview_no_providers_hint" = "No enabled providers available for Overview."; +"overview_rows_follow_order" = "Overview rows always follow provider order."; +"overview_no_providers_selected" = "No providers selected"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Keyboard shortcut"; +"open_menu_shortcut_title" = "Open menu"; +"open_menu_shortcut_subtitle" = "Trigger the menu bar menu from anywhere."; +"install_cli" = "Install CLI"; +"install_cli_subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."; +"cli_not_found" = "CodexBarCLI not found in app bundle."; +"no_writable_bin_dirs" = "No writable bin dirs found."; +"show_debug_settings_title" = "Show Debug Settings"; +"show_debug_settings_subtitle" = "Expose troubleshooting tools in the Debug tab."; +"surprise_me_title" = "Surprise me"; +"surprise_me_subtitle" = "Check if you like your agents having some fun up there."; +"weekly_limit_confetti_title" = "Weekly limit confetti"; +"weekly_limit_confetti_subtitle" = "Play full-screen confetti when weekly usage resets."; +"hide_personal_info_title" = "Hide personal information"; +"hide_personal_info_subtitle" = "Obscure email addresses in the menu bar and menu UI."; +"show_provider_storage_usage_title" = "Show provider storage usage"; +"show_provider_storage_usage_subtitle" = "Show local disk usage in menus. Scans known provider-owned paths in the background."; +"section_keychain_access" = "Keychain access"; +"keychain_access_caption" = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers."; +"disable_keychain_access_title" = "Disable Keychain access"; +"disable_keychain_access_subtitle" = "Prevents any Keychain access while enabled."; + +/* About Pane */ +"about_tagline" = "May your tokens never run out—keep agent limits in view."; +"link_github" = "GitHub"; +"link_website" = "Website"; +"link_twitter" = "Twitter"; +"link_email" = "Email"; +"check_updates_auto" = "Check for updates automatically"; +"update_channel" = "Update Channel"; +"check_for_updates" = "Check for Updates…"; +"updates_unavailable" = "Updates unavailable in this build."; +"copyright" = "© 2026 Peter Steinberger. MIT License."; + +/* Debug Pane */ +"section_logging" = "Logging"; +"enable_file_logging" = "Enable file logging"; +"enable_file_logging_subtitle" = "Write logs to %@ for debugging."; +"verbosity_title" = "Verbosity"; +"verbosity_subtitle" = "Controls how much detail is logged."; +"open_log_file" = "Open log file"; +"force_animation_next_refresh" = "Force animation on next refresh"; +"force_animation_next_refresh_subtitle" = "Temporarily shows the loading animation after the next refresh."; +"section_loading_animations" = "Loading animations"; +"loading_animations_caption" = "Pick a pattern and replay it in the menu bar. \"Random\" keeps the existing behavior."; +"animation_random_default" = "Random (default)"; +"replay_selected_animation" = "Replay selected animation"; +"blink_now" = "Blink now"; +"section_probe_logs" = "Probe logs"; +"probe_logs_caption" = "Fetch the latest probe output for debugging; Copy keeps the full text."; +"fetch_log" = "Fetch log"; +"copy" = "Copy"; +"save_to_file" = "Save to file"; +"load_parse_dump" = "Load parse dump"; +"rerun_provider_autodetect" = "Re-run provider autodetect"; +"loading" = "Loading…"; +"no_log_yet_fetch" = "No log yet. Fetch to load."; +"section_fetch_strategy" = "Fetch strategy attempts"; +"fetch_strategy_caption" = "Last fetch pipeline decisions and errors for a provider."; +"section_openai_cookies" = "OpenAI cookies"; +"openai_cookies_caption" = "Cookie import + WebKit scrape logs from the last OpenAI cookies attempt."; +"no_log_yet" = "No log yet. Update OpenAI cookies in Providers → Codex to run an import."; +"section_caches" = "Caches"; +"caches_caption" = "Clear cached cost scan results or browser cookie caches."; +"clear_cookie_cache" = "Clear cookie cache"; +"clear_cost_cache" = "Clear cost cache"; +"section_notifications" = "Notifications"; +"notifications_caption" = "Trigger test notifications for the 5-hour session window (depleted/restored)."; +"post_depleted" = "Post depleted"; +"post_restored" = "Post restored"; +"section_cli_sessions" = "CLI sessions"; +"cli_sessions_caption" = "Keep Codex/Claude CLI sessions alive after a probe. Default exits once data is captured."; +"keep_cli_sessions_alive" = "Keep CLI sessions alive"; +"keep_cli_sessions_alive_subtitle" = "Skip teardown between probes (debug-only)."; +"reset_cli_sessions" = "Reset CLI sessions"; +"section_error_simulation" = "Error simulation"; +"error_simulation_caption" = "Inject a fake error message into the menu card for layout testing."; +"set_menu_error" = "Set menu error"; +"clear_menu_error" = "Clear menu error"; +"set_cost_error" = "Set cost error"; +"clear_cost_error" = "Clear cost error"; +"section_cli_paths" = "CLI paths"; +"cli_paths_caption" = "Resolved Codex binary and PATH layers; startup login PATH capture (short timeout)."; +"codex_binary" = "Codex binary"; +"claude_binary" = "Claude binary"; +"effective_path" = "Effective PATH"; +"unavailable" = "Unavailable"; +"login_shell_path" = "Login shell PATH (startup capture)"; +"cleared" = "Cleared."; +"no_fetch_attempts" = "No fetch attempts yet."; +"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 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."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatic"; +"metric_pref_primary" = "Primary"; +"metric_pref_secondary" = "Secondary"; +"metric_pref_tertiary" = "Tertiary"; +"metric_pref_extra_usage" = "Extra usage"; +"metric_pref_average" = "Average"; + +/* Display modes */ +"display_mode_percent" = "Percent"; +"display_mode_pace" = "Pace"; +"display_mode_both" = "Both"; +"display_mode_percent_desc" = "Show remaining/used percentage (e.g. 45%)"; +"display_mode_pace_desc" = "Show pace indicator (e.g. +5%)"; +"display_mode_both_desc" = "Show both percentage and pace (e.g. 45% · +5%)"; + +/* Provider status */ +"status_operational" = "Operational"; +"status_partial_outage" = "Partial outage"; +"status_major_outage" = "Major outage"; +"status_critical_issue" = "Critical issue"; +"status_maintenance" = "Maintenance"; +"status_unknown" = "Status unknown"; + +/* Refresh frequency */ +"refresh_manual" = "Manual"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 min"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 min"; +"refresh_30min" = "30 min"; + +/* Cost estimation */ +"cost_header_estimated" = "Cost (estimated)"; +"cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings new file mode 100644 index 000000000..096ec78a8 --- /dev/null +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -0,0 +1,636 @@ +/* Brazilian Portuguese localization for CodexBar */ + +" providers" = " provedores"; +"(System)" = "(Sistema)"; +"30d" = "30d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; +"API key" = "Chave de API"; +"API region" = "Região da API"; +"API token" = "Token da API"; +"API tokens" = "Tokens de API"; +"About" = "Sobre"; +"Account" = "Conta"; +"Accounts" = "Contas"; +"Accounts subtitle" = "Subtítulo de contas"; +"Active" = "Ativo"; +"Add" = "Adicionar"; +"Add Workspace" = "Adicionar workspace"; +"Advanced" = "Avançado"; +"All" = "Todos"; +"Always allow prompts" = "Sempre permitir prompts"; +"Animation pattern" = "Padrão de animação"; +"Antigravity login is managed in the app" = "O login do Antigravity é gerenciado no app"; +"Applies only to the Security.framework OAuth keychain reader." = "Aplica-se apenas ao leitor de chaves OAuth Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Automático usa a próxima fonte se a preferida falhar."; +"Auto uses API first, then falls back to CLI on auth failures." = "Automático usa a API primeiro e recorre à CLI em falhas de autenticação."; +"Auto-detect" = "Detectar automaticamente"; +"Auto-refresh is off; use the menu's Refresh command." = "A atualização automática está desativada; use Atualizar no menu."; +"Auto-refresh: hourly · Timeout: 10m" = "Atualização automática: a cada hora · Timeout: 10 min"; +"Automatic" = "Automático"; +"Automatic imports browser cookies and WorkOS tokens." = "Importa automaticamente cookies do navegador e tokens WorkOS."; +"Automatic imports browser cookies and local storage tokens." = "Importa automaticamente cookies do navegador e tokens do armazenamento local."; +"Automatic imports browser cookies for dashboard extras." = "Importa automaticamente cookies do navegador para extras do dashboard."; +"Automatic imports browser cookies for the web API." = "Importa automaticamente cookies do navegador para a API web."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importa automaticamente cookies do navegador do Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importa automaticamente cookies do navegador de admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importa automaticamente cookies do navegador de opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importa automaticamente cookies do navegador ou sessões salvas."; +"Automatic imports browser cookies." = "Importa cookies do navegador automaticamente."; +"Automatically imports browser session cookie." = "Importa automaticamente o cookie de sessão do navegador."; +"Automatically opens CodexBar when you start your Mac." = "Abre o CodexBar automaticamente ao iniciar o Mac."; +"Automation" = "Automação"; +"Average (\\(label1) + \\(label2))" = "Média (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Média (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Evitar prompts do Keychain"; +"Balance" = "Saldo"; +"Battery Saver" = "Economia de bateria"; +"Bordered" = "Com borda"; +"Build" = "Build"; +"Built \\(buildTimestamp)" = "Build \\(buildTimestamp)"; +"Buy Credits..." = "Comprar créditos..."; +"Buy Credits…" = "Comprar créditos…"; +"CLI paths" = "Caminhos da CLI"; +"CLI sessions" = "Sessões da CLI"; +"Caches" = "Caches"; +"Cancel" = "Cancelar"; +"Check for Updates…" = "Buscar atualizações…"; +"Check for updates automatically" = "Buscar atualizações automaticamente"; +"Check if you like your agents having some fun up there." = "Veja se você gosta dos seus agentes se divertindo ali em cima."; +"Check provider status" = "Verificar status dos provedores"; +"Choose Codex workspace" = "Escolher workspace do Codex"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Escolha o host MiniMax (global .io ou China continental .com)."; +"Choose up to " = "Escolha até "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Escolha até \\(Self.maxOverviewProviders) provedores"; +"Choose up to \\(count) providers" = "Escolha até \\(count) provedores"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Escolha o que mostrar na barra de menus (Ritmo mostra uso vs. esperado)."; +"Choose which Codex account CodexBar should follow." = "Escolha qual conta Codex o CodexBar deve acompanhar."; +"Choose which window drives the menu bar percent." = "Escolha qual janela define a porcentagem da barra de menus."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "CLI do Claude não encontrada"; +"Claude binary" = "Binário do Claude"; +"Claude cookies" = "Cookies do Claude"; +"Claude login failed" = "Falha no login do Claude"; +"Claude login timed out" = "Tempo esgotado no login do Claude"; +"Close" = "Fechar"; +"Code review" = "Revisão de código"; +"Codex CLI not found" = "CLI do Codex não encontrada"; +"Codex account login already running" = "Login de conta Codex já em andamento"; +"Codex binary" = "Binário do Codex"; +"Codex login failed" = "Falha no login do Codex"; +"Codex login timed out" = "Tempo esgotado no login do Codex"; +"CodexBar Lifecycle Keepalive" = "Keepalive do ciclo de vida do CodexBar"; +"CodexBar can't show its menu bar icon" = "O CodexBar não consegue mostrar o ícone na barra de menus"; +"CodexBar could not read managed account storage. " = "O CodexBar não conseguiu ler o armazenamento de contas gerenciadas. "; +"Configure…" = "Configurar…"; +"Connected" = "Conectado"; +"Controls how much detail is logged." = "Controla o nível de detalhe dos logs."; +"Cookie header" = "Cabeçalho Cookie"; +"Cookie source" = "Fonte do cookie"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\nou cole uma captura cURL do dashboard do Abacus AI"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\nou cole o valor de __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\nou cole o valor do token kimi-auth"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Custo"; +"Could not add Codex account" = "Não foi possível adicionar a conta Codex"; +"Could not open Terminal for Gemini" = "Não foi possível abrir o Terminal para Gemini"; +"Could not start claude /login" = "Não foi possível iniciar claude /login"; +"Could not start codex login" = "Não foi possível iniciar o login do Codex"; +"Could not switch system account" = "Não foi possível trocar a conta do sistema"; +"Credits" = "Créditos"; +"Credits history" = "Histórico de créditos"; +"Cursor login failed" = "Falha no login do Cursor"; +"Custom" = "Personalizado"; +"Custom Path" = "Caminho personalizado"; +"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"; +"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."; +"Done" = "Concluído"; +"Effective PATH" = "PATH efetivo"; +"Email" = "E-mail"; +"Enable Merge Icons to configure Overview tab providers." = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; +"Enable file logging" = "Ativar logs em arquivo"; +"Enabled" = "Ativado"; +"Error" = "Erro"; +"Error simulation" = "Simulação de erro"; +"Expose troubleshooting tools in the Debug tab." = "Exibe ferramentas de diagnóstico na aba Depuração."; +"Failed" = "Falhou"; +"False" = "Falso"; +"Fetch strategy attempts" = "Tentativas da estratégia de busca"; +"Fetching" = "Buscando"; +"Field" = "Campo"; +"Field subtitle" = "Subtítulo do campo"; +"Finish the current managed account change before switching the system account." = "Conclua a alteração de conta gerenciada atual antes de trocar a conta do sistema."; +"Force animation on next refresh" = "Forçar animação na próxima atualização"; +"Gateway region" = "Região do gateway"; +"Gemini CLI not found" = "CLI do Gemini não encontrada"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity, exibindo incidentes no ícone e no menu."; +"General" = "Geral"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "Login do GitHub Copilot"; +"GitHub Login" = "Login do GitHub"; +"Hide details" = "Ocultar detalhes"; +"Hide personal information" = "Ocultar informações pessoais"; +"Historical tracking" = "Acompanhamento histórico"; +"How often CodexBar polls providers in the background." = "Frequência com que o CodexBar consulta provedores em segundo plano."; +"Inactive" = "Inativo"; +"Install CLI" = "Instalar CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Instale a CLI do Claude (npm i -g @anthropic-ai/claude-code) e tente novamente."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Instale a CLI do Codex (npm i -g @openai/codex) e tente novamente."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Instale a CLI do Gemini (npm i -g @google/gemini-cli) e tente novamente."; +"JetBrains AI is ready" = "JetBrains AI está pronto"; +"JetBrains IDE" = "IDE JetBrains"; +"Keep CLI sessions alive" = "Manter sessões da CLI ativas"; +"Keyboard shortcut" = "Atalho de teclado"; +"Keychain access" = "Acesso ao Keychain"; +"Keychain prompt policy" = "Política de prompts do Keychain"; +"Last \\(name) fetch failed:" = "Última busca de \\(name) falhou:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Última busca de \\(self.store.metadata(for: self.provider).displayName) falhou:"; +"Last attempt" = "Última tentativa"; +"Link" = "Link"; +"Loading animations" = "Animações de carregamento"; +"Loading…" = "Carregando…"; +"Local" = "Local"; +"Logging" = "Logs"; +"Login failed" = "Falha no login"; +"Login shell PATH (startup capture)" = "PATH do shell de login (captura na inicialização)"; +"Login timed out" = "Tempo esgotado no login"; +"MCP details" = "Detalhes MCP"; +"Managed Codex accounts unavailable" = "Contas Codex gerenciadas indisponíveis"; +"Managed account storage is unreadable. Live account access is still available, " = "O armazenamento de contas gerenciadas está ilegível. O acesso à conta ativa ainda está disponível, "; +"Manual" = "Manual"; +"May your tokens never run out—keep agent limits in view." = "Que seus tokens nunca acabem — mantenha os limites dos agentes à vista."; +"Menu bar" = "Barra de menus"; +"Menu bar auto-shows the provider closest to its rate limit." = "A barra de menus mostra automaticamente o provedor mais próximo do limite de taxa."; +"Menu bar metric" = "Métrica da barra de menus"; +"Menu bar shows percent" = "Barra de menus mostra porcentagem"; +"Menu content" = "Conteúdo do menu"; +"Merge Icons" = "Mesclar Ícones"; +"Never prompt" = "Nunca perguntar"; +"No" = "Não"; +"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."; +"No providers selected" = "Nenhum provedor selecionado"; +"No token accounts yet." = "Ainda sem contas de token."; +"No usage breakdown data." = "Sem dados de detalhamento de uso."; +"None" = "Nenhum"; +"Notifications" = "Notificações"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Notifica quando a cota de sessão de 5 horas chega a 0% e quando fica "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Oculta endereços de e-mail na barra de menus e na UI do menu."; +"Off" = "Desligado"; +"Off-peak" = "Fora do pico"; +"Off-peak · peak in \\(self.formatDuration(minutes: minutes))" = "Fora do pico · pico em \\(self.formatDuration(minutes: minutes))"; +"Offline" = "Offline"; +"On" = "Ligado"; +"Online" = "Online"; +"Only on user action" = "Somente por ação do usuário"; +"Open" = "Abrir"; +"Open API Keys" = "Abrir chaves de API"; +"Open Amp Settings" = "Abrir ajustes do Amp"; +"Open Antigravity to sign in, then refresh CodexBar." = "Abra o Antigravity para entrar e depois atualize o CodexBar."; +"Open Browser" = "Abrir navegador"; +"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 Ollama Settings" = "Abrir ajustes do Ollama"; +"Open Terminal" = "Abrir Terminal"; +"Open Usage Page" = "Abrir página de uso"; +"Open Warp API Key Guide" = "Abrir guia de chave de API do Warp"; +"Open menu" = "Abrir menu"; +"Open token file" = "Abrir arquivo de token"; +"OpenAI cookies" = "Cookies da OpenAI"; +"OpenAI web extras" = "Extras web da OpenAI"; +"Option A" = "Opção A"; +"Option B" = "Opção B"; +"Optional override if workspace lookup fails." = "Substituição opcional se a busca do workspace falhar."; +"Options" = "Opções"; +"Override auto-detection with a custom IDE base path" = "Substituir detecção automática por um caminho base personalizado da IDE"; +"Overview" = "Visão geral"; +"Overview rows always follow provider order." = "As linhas da Visão geral sempre seguem a ordem dos provedores."; +"Overview tab providers" = "Provedores da aba Visão geral"; +"Paste API key…" = "Cole a chave de API…"; +"Paste API token…" = "Cole o token da API…"; +"Paste key…" = "Cole a chave…"; +"Paste sessionKey or OAuth token…" = "Cole sessionKey ou token OAuth…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Cole o cabeçalho Cookie de uma requisição para admin.mistral.ai. "; +"Paste token…" = "Cole o token…"; +"Peak · ends in \\(self.formatDuration(minutes: remaining))" = "Pico · termina em \\(self.formatDuration(minutes: remaining))"; +"Personal" = "Pessoal"; +"Picker" = "Seletor"; +"Picker subtitle" = "Subtítulo do seletor"; +"Placeholder" = "Texto de exemplo"; +"Plan" = "Plano"; +"Play full-screen confetti when weekly usage resets." = "Mostra confete em tela cheia quando o uso semanal for renovado."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Consulta as páginas de status da OpenAI/Claude e o Google Workspace para "; +"Prevents any Keychain access while enabled." = "Impede qualquer acesso ao Keychain quando ativado."; +"Primary (API key limit)" = "Primário (limite da chave de API)"; +"Primary (\\(label))" = "Primário (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primário (\\(metadata.sessionLabel))"; +"Probe logs" = "Logs de verificação"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "As barras de progresso preenchem conforme você consome a cota (em vez de mostrar o restante)."; +"Provider" = "Provedor"; +"Providers" = "Provedores"; +"Quit CodexBar" = "Encerrar CodexBar"; +"Random (default)" = "Aleatório (padrão)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Lê logs de uso locais. Mostra o custo de hoje + últimos 30 dias no menu."; +"Refresh" = "Atualizar"; +"Refresh cadence" = "Cadência de atualização"; +"Remote" = "Remoto"; +"Remove" = "Remover"; +"Remove Codex account?" = "Remover conta Codex?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Remover \\(account.email) do CodexBar? O diretório Codex gerenciado será apagado."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Remover \\(email) do CodexBar? O diretório Codex gerenciado será apagado."; +"Remove selected account" = "Remover conta selecionada"; +"Replace critter bars with provider branding icons and a percentage." = "Substitui barras de bichinhos por ícones da marca do provedor e uma porcentagem."; +"Replay selected animation" = "Reproduzir animação selecionada"; +"Requires authentication via GitHub Device Flow." = "Requer autenticação via GitHub Device Flow."; +"Resets: \\(reset)" = "Renova em: \\(reset)"; +"Rolling five-hour limit" = "Limite móvel de cinco horas"; +"Search hourly" = "Buscar a cada hora"; +"Secondary (\\(label))" = "Secundário (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Secundário (\\(metadata.weeklyLabel))"; +"Select a provider" = "Selecione um provedor"; +"Select the IDE to monitor" = "Selecione a IDE para monitorar"; +"Session quota notifications" = "Notificações de cota de sessão"; +"Session tokens" = "Tokens de sessão"; +"Settings" = "Ajustes"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; +"Show Debug Settings" = "Mostrar ajustes de depuração"; +"Show all token accounts" = "Mostrar todas as contas de token"; +"Show cost summary" = "Mostrar resumo de custos"; +"Show credits + extra usage" = "Mostrar créditos + uso extra"; +"Show details" = "Mostrar detalhes"; +"Show most-used provider" = "Mostrar provedor mais usado"; +"Show peak hours indicator" = "Mostrar indicador de horário de pico"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Mostra ícones dos provedores no alternador (caso contrário, mostra uma linha de progresso semanal)."; +"Show reset time as clock" = "Mostrar renovação como horário"; +"Show usage as used" = "Mostrar uso como consumido"; +"Show whether Claude is in peak usage hours." = "Mostra se o Claude está em horário de pico."; +"Sign in via button below" = "Entre pelo botão abaixo"; +"Skip teardown between probes (debug-only)." = "Não encerra entre verificações (somente depuração)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Empilha contas de token no menu (caso contrário, mostra uma barra de alternância de contas)."; +"Start at Login" = "Iniciar ao fazer login"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Armazena cookies sessionKey ou tokens de acesso OAuth do Claude."; +"Store multiple Abacus AI Cookie headers." = "Armazena vários cabeçalhos Cookie do Abacus AI."; +"Store multiple Augment Cookie headers." = "Armazena vários cabeçalhos Cookie do Augment."; +"Store multiple Cursor Cookie headers." = "Armazena vários cabeçalhos Cookie do Cursor."; +"Store multiple Factory Cookie headers." = "Armazena vários cabeçalhos Cookie do Factory."; +"Store multiple MiniMax Cookie headers." = "Armazena vários cabeçalhos Cookie do MiniMax."; +"Store multiple Mistral Cookie headers." = "Armazena vários cabeçalhos Cookie do Mistral."; +"Store multiple Ollama Cookie headers." = "Armazena vários cabeçalhos Cookie do Ollama."; +"Store multiple OpenCode Cookie headers." = "Armazena vários cabeçalhos Cookie do OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Armazena vários cabeçalhos Cookie do OpenCode Go."; +"Stored in the CodexBar config file." = "Armazenado no arquivo de configuração do CodexBar."; +"Stored in ~/.codexbar/config.json. " = "Armazenado em ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Armazenado em ~/.codexbar/config.json. Gere um em kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Armazenado em ~/.codexbar/config.json. Cole a chave do dashboard Synthetic."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Armazenado em ~/.codexbar/config.json. Cole sua chave de API do Coding Plan do Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Armazenado em ~/.codexbar/config.json. Cole sua chave de API do MiniMax."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Armazenado em ~/.codexbar/config.json. Você também pode informar KILO_API_KEY ou "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Armazena histórico local de uso do Codex (8 semanas) para personalizar previsões de Ritmo."; +"Subscription Utilization" = "Uso da assinatura"; +"Surprise me" = "Surpreenda-me"; +"Switcher shows icons" = "Alternador mostra ícones"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Cria symlink de CodexBarCLI em /usr/local/bin e /opt/homebrew/bin como codexbar."; +"System" = "Sistema"; +"Temporarily shows the loading animation after the next refresh." = "Mostra temporariamente a animação de carregamento após a próxima atualização."; +"Tertiary (\\(label))" = "Terciário (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Terciário (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "A conta Codex padrão deste Mac."; +"Toggle" = "Alternar"; +"Toggle subtitle" = "Subtítulo do alternador"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Aciona o menu da barra de menus de qualquer lugar."; +"True" = "Verdadeiro"; +"Twitter" = "Twitter"; +"Unsupported" = "Não suportado"; +"Update Channel" = "Canal de atualização"; +"Updated" = "Atualizado"; +"Updates unavailable in this build." = "Atualizações indisponíveis nesta build."; +"Usage" = "Uso"; +"Usage breakdown" = "Detalhamento de uso"; +"Usage history (30 days)" = "Histórico de uso (30 dias)"; +"Usage source" = "Fonte de uso"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Usa BigModel para endpoints da China continental (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Usa um único ícone na barra de menus com alternador de provedores."; +"Use international or China mainland console gateways for quota fetches." = "Usa gateways de console internacionais ou da China continental para buscar cotas."; +"Version" = "Versão"; +"Version \\(self.versionString)" = "Versão \\(self.versionString)"; +"Version \\(version)" = "Versão \\(version)"; +"Version \\(versionString)" = "Versão \\(versionString)"; +"Vertex AI Login" = "Login do Vertex AI"; +"Wait for the current managed Codex login to finish before adding another account." = "Aguarde o login gerenciado atual do Codex terminar antes de adicionar outra conta."; +"Waiting for Authentication..." = "Aguardando autenticação..."; +"Website" = "Site"; +"Weekly limit confetti" = "Confete do limite semanal"; +"Weekly token limit" = "Limite semanal de tokens"; +"Weekly usage" = "Uso semanal"; +"Weekly usage unavailable for this account." = "Uso semanal indisponível para esta conta."; +"Window: \\(window)" = "Janela: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Grava logs em \\(self.fileLogPath) para depuração."; +"Yes" = "Sim"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): buscando…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): última tentativa \\(when)"; +"\\(name): no data yet" = "\\(name): ainda sem dados"; +"\\(name): unsupported" = "\\(name): não suportado"; +"all browsers" = "todos os navegadores"; +"available again." = "disponível novamente."; +"built_format" = "Build %@"; +"copilot_complete_in_browser" = "Conclua o login no navegador."; +"copilot_device_code" = "Código do dispositivo copiado para a área de transferência: %1$@\n\nVerifique em: %2$@"; +"copilot_device_code_copied" = "Código do dispositivo copiado."; +"copilot_verify_at" = "Verifique em %@"; +"copilot_waiting_text" = "Conclua o login no navegador.\nEsta janela fecha automaticamente quando o login for concluído."; +"copilot_window_closes_auto" = "Esta janela fecha automaticamente quando o login for concluído."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: buscando… %2$@"; +"cost_status_last_attempt" = "%1$@: última tentativa %2$@"; +"cost_status_no_data" = "%@: ainda sem dados"; +"cost_status_snapshot" = "%1$@: %2$@ · 30d %3$@"; +"cost_status_unsupported" = "%@: não suportado"; +"credits_remaining" = "Créditos: %@"; +"cursor_on_demand" = "Sob demanda: %@"; +"cursor_on_demand_with_limit" = "Sob demanda: %1$@ / %2$@"; +"extra_usage_format" = "Uso extra: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Detectado: %@. Use o assistente de IA uma vez para gerar dados de cota e atualize o CodexBar."; +"jetbrains_detected_select" = "Detectado: %@. Selecione sua IDE preferida em Ajustes e atualize o CodexBar."; +"last_fetch_failed_with_provider" = "Última busca de %@ falhou:"; +"last_spend" = "Último gasto: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Renova em: %@"; +"mcp_window" = "Janela: %@"; +"metric_average" = "Média (%1$@ + %2$@)"; +"metric_primary" = "Primário (%@)"; +"metric_secondary" = "Secundário (%@)"; +"metric_tertiary" = "Terciário (%@)"; +"multiple_workspaces_found" = "O CodexBar encontrou vários workspaces para %@. Escolha o workspace para adicionar."; +"off_peak" = "Fora do pico"; +"off_peak_peak_in" = "Fora do pico · pico em %@"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Escolha até %@ provedores"; +"peak_ends_in" = "Pico termina em %@"; +"remove_account_message" = "Remover %@ do CodexBar? O diretório Codex gerenciado será apagado."; +"version_format" = "Versão %@"; +"vertex_ai_login_instructions" = "Para acompanhar o uso do Vertex AI, autentique-se no Google Cloud.\n\n1. Abra o Terminal\n2. Execute: gcloud auth application-default login\n3. Siga os prompts no navegador para entrar\n4. Defina seu projeto: gcloud config set project PROJECT_ID\n\nAbrir o Terminal agora?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID está definido, mas somente opencode, opencodego e deepgram oferecem suporte a workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. Licença MIT."; + +/* General Pane */ +"section_system" = "Sistema"; +"section_usage" = "Uso"; +"section_automation" = "Automação"; +"language_title" = "Idioma"; +"language_subtitle" = "Altera o idioma de exibição. Requer reiniciar o app para ter efeito completo."; +"language_system" = "Sistema"; +"language_english" = "Inglês"; +"language_chinese_simplified" = "Chinês simplificado"; +"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."; +"show_cost_summary" = "Mostrar resumo de custos"; +"show_cost_summary_subtitle" = "Lê logs de uso locais. Mostra o custo de hoje + últimos 30 dias no menu."; +"cost_auto_refresh_info" = "Atualização automática: a cada hora · Timeout: 10 min"; +"refresh_cadence_title" = "Cadência de atualização"; +"refresh_cadence_subtitle" = "Frequência com que o CodexBar consulta provedores em segundo plano."; +"manual_refresh_hint" = "A atualização automática está desativada; use Atualizar no menu."; +"check_provider_status_title" = "Verificar status dos provedores"; +"check_provider_status_subtitle" = "Consulta páginas de status da OpenAI/Claude e o Google Workspace para Gemini/Antigravity, exibindo incidentes no ícone e no menu."; +"session_quota_notifications_title" = "Notificações de cota de sessão"; +"session_quota_notifications_subtitle" = "Notifica quando a cota de sessão de 5 horas chega a 0% e quando fica disponível novamente."; +"quota_warning_notifications_title" = "Notificações de alerta de cota"; +"quota_warning_notifications_subtitle" = "Avisa quando a cota restante da sessão ou da semana fica abaixo dos limites configurados."; +"quota_warnings_title" = "Alertas de cota"; +"quota_warning_session" = "sessão"; +"quota_warning_session_capitalized" = "Sessão"; +"quota_warning_weekly" = "semanal"; +"quota_warning_weekly_capitalized" = "Semanal"; +"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"; +"quota_warning_provider_inherits" = "Usa as configurações globais de alerta de cota, a menos que uma janela seja personalizada aqui."; +"quota_warning_customize_thresholds" = "Personalizar limites de %@"; +"quota_warning_enable_warnings" = "Ativar alertas de %@"; +"quota_warning_window_warn_at" = "%@: alertar em"; +"quota_warning_off" = "Desativado"; +"quota_warning_inherited" = "Usando global: %@"; +"quota_warning_depleted_only" = "somente ao esgotar"; +"quota_warning_upper" = "Limite superior"; +"quota_warning_lower" = "Limite inferior"; +"apply" = "Aplicar"; +"quit_app" = "Encerrar CodexBar"; + +/* Tab titles */ +"tab_general" = "Geral"; +"tab_providers" = "Provedores"; +"tab_display" = "Exibição"; +"tab_advanced" = "Avançado"; +"tab_about" = "Sobre"; +"tab_debug" = "Depuração"; + +/* Providers Pane */ +"select_a_provider" = "Selecione um provedor"; +"cancel" = "Cancelar"; +"last_fetch_failed" = "última busca falhou"; +"usage_not_fetched_yet" = "uso ainda não buscado"; +"managed_account_storage_unreadable" = "O armazenamento de contas gerenciadas está ilegível. O acesso à conta ativa ainda está disponível, mas adicionar, reautenticar e remover contas gerenciadas ficam desativados até o armazenamento ser recuperável."; +"remove_codex_account_title" = "Remover conta Codex?"; +"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."; +"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."; +"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"; +"menu_bar_metric_subtitle" = "Escolha qual janela define a porcentagem da barra de menus."; +"menu_bar_metric_subtitle_deepseek" = "Mostra o saldo do DeepSeek na barra de menus."; +"menu_bar_metric_subtitle_moonshot" = "Mostra o saldo da API Moonshot / Kimi na barra de menus."; +"menu_bar_metric_subtitle_mistral" = "Mostra o gasto da API Mistral no mês atual na barra de menus."; +"menu_bar_metric_subtitle_kimik2" = "Mostra os créditos da chave de API do Kimi K2 na barra de menus."; +"automatic" = "Automático"; +"primary_api_key_limit" = "Primário (limite da chave de API)"; + +/* Display Pane */ +"section_menu_bar" = "Barra de menus"; +"merge_icons_title" = "Mesclar Ícones"; +"merge_icons_subtitle" = "Usa um único ícone na barra de menus com alternador de provedores."; +"switcher_shows_icons_title" = "Alternador mostra ícones"; +"switcher_shows_icons_subtitle" = "Mostra ícones dos provedores no alternador (caso contrário, mostra uma linha de progresso semanal)."; +"show_most_used_provider_title" = "Mostrar provedor mais usado"; +"show_most_used_provider_subtitle" = "A barra de menus mostra automaticamente o provedor mais próximo do limite de taxa."; +"menu_bar_shows_percent_title" = "Barra de menus mostra porcentagem"; +"menu_bar_shows_percent_subtitle" = "Substitui barras de bichinhos por ícones da marca do provedor e uma porcentagem."; +"display_mode_title" = "Modo de exibição"; +"display_mode_subtitle" = "Escolha o que mostrar na barra de menus (Ritmo mostra uso vs. esperado)."; +"section_menu_content" = "Conteúdo do menu"; +"show_usage_as_used_title" = "Mostrar uso como consumido"; +"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."; +"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"; +"show_provider_changelog_links_subtitle" = "Adiciona links de notas de versão para provedores baseados em CLI compatíveis no menu."; +"show_credits_extra_usage_title" = "Mostrar créditos + uso extra"; +"show_credits_extra_usage_subtitle" = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; +"show_all_token_accounts_title" = "Mostrar todas as contas de token"; +"show_all_token_accounts_subtitle" = "Empilha contas de token no menu (caso contrário, mostra uma barra de alternância de contas)."; +"multi_account_layout_title" = "Layout de múltiplas contas"; +"multi_account_layout_subtitle" = "Escolha alternância segmentada de contas ou cartões de contas empilhados."; +"multi_account_layout_segmented" = "Segmentado"; +"multi_account_layout_stacked" = "Empilhado"; +"overview_tab_providers_title" = "Provedores da aba Visão geral"; +"configure" = "Configurar…"; +"overview_enable_merge_icons_hint" = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; +"overview_no_providers_hint" = "Nenhum provedor ativado disponível para Visão geral."; +"overview_rows_follow_order" = "As linhas da Visão geral sempre seguem a ordem dos provedores."; +"overview_no_providers_selected" = "Nenhum provedor selecionado"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Atalho de teclado"; +"open_menu_shortcut_title" = "Abrir menu"; +"open_menu_shortcut_subtitle" = "Aciona o menu da barra de menus de qualquer lugar."; +"install_cli" = "Instalar CLI"; +"install_cli_subtitle" = "Cria symlink de CodexBarCLI em /usr/local/bin e /opt/homebrew/bin como codexbar."; +"cli_not_found" = "CodexBarCLI não encontrado no pacote do app."; +"no_writable_bin_dirs" = "Nenhum diretório bin gravável encontrado."; +"show_debug_settings_title" = "Mostrar ajustes de depuração"; +"show_debug_settings_subtitle" = "Exibe ferramentas de diagnóstico na aba Depuração."; +"surprise_me_title" = "Surpreenda-me"; +"surprise_me_subtitle" = "Veja se você gosta dos seus agentes se divertindo ali em cima."; +"weekly_limit_confetti_title" = "Confete do limite semanal"; +"weekly_limit_confetti_subtitle" = "Mostra confete em tela cheia quando o uso semanal for renovado."; +"hide_personal_info_title" = "Ocultar informações pessoais"; +"hide_personal_info_subtitle" = "Oculta endereços de e-mail na barra de menus e na UI do menu."; +"show_provider_storage_usage_title" = "Mostrar uso de armazenamento dos provedores"; +"show_provider_storage_usage_subtitle" = "Mostra o uso de disco local nos menus. Verifica em segundo plano caminhos conhecidos pertencentes aos provedores."; +"section_keychain_access" = "Acesso ao Keychain"; +"keychain_access_caption" = "Desativa todas as leituras e gravações do Keychain. A importação de cookies do navegador fica indisponível; cole cabeçalhos Cookie manualmente em Provedores."; +"disable_keychain_access_title" = "Desativar acesso ao Keychain"; +"disable_keychain_access_subtitle" = "Impede qualquer acesso ao Keychain quando ativado."; + +/* About Pane */ +"about_tagline" = "Que seus tokens nunca acabem — mantenha os limites dos agentes à vista."; +"link_github" = "GitHub"; +"link_website" = "Site"; +"link_twitter" = "Twitter"; +"link_email" = "E-mail"; +"check_updates_auto" = "Buscar atualizações automaticamente"; +"update_channel" = "Canal de atualização"; +"check_for_updates" = "Buscar atualizações…"; +"updates_unavailable" = "Atualizações indisponíveis nesta build."; +"copyright" = "© 2026 Peter Steinberger. Licença MIT."; + +/* Debug Pane */ +"section_logging" = "Logs"; +"enable_file_logging" = "Ativar logs em arquivo"; +"enable_file_logging_subtitle" = "Grava logs em %@ para depuração."; +"verbosity_title" = "Verbosidade"; +"verbosity_subtitle" = "Controla o nível de detalhe dos logs."; +"open_log_file" = "Abrir arquivo de log"; +"force_animation_next_refresh" = "Forçar animação na próxima atualização"; +"force_animation_next_refresh_subtitle" = "Mostra temporariamente a animação de carregamento após a próxima atualização."; +"section_loading_animations" = "Animações de carregamento"; +"loading_animations_caption" = "Escolha um padrão e reproduza na barra de menus. \"Aleatório\" mantém o comportamento atual."; +"animation_random_default" = "Aleatório (padrão)"; +"replay_selected_animation" = "Reproduzir animação selecionada"; +"blink_now" = "Piscar agora"; +"section_probe_logs" = "Logs de verificação"; +"probe_logs_caption" = "Busca a saída mais recente da verificação para depuração; Copiar mantém o texto completo."; +"fetch_log" = "Buscar log"; +"copy" = "Copiar"; +"save_to_file" = "Salvar em arquivo"; +"load_parse_dump" = "Carregar dump de análise"; +"rerun_provider_autodetect" = "Executar novamente a detecção automática de provedores"; +"loading" = "Carregando…"; +"no_log_yet_fetch" = "Ainda sem log. Busque para carregar."; +"section_fetch_strategy" = "Tentativas da estratégia de busca"; +"fetch_strategy_caption" = "Últimas decisões e erros do pipeline de busca de um provedor."; +"section_openai_cookies" = "Cookies da OpenAI"; +"openai_cookies_caption" = "Logs de importação de cookies + scraping WebKit da última tentativa de cookies da OpenAI."; +"no_log_yet" = "Ainda sem log. Atualize os cookies da OpenAI em Provedores → Codex para executar uma importação."; +"section_caches" = "Caches"; +"caches_caption" = "Limpa resultados de varredura de custo em cache ou caches de cookies do navegador."; +"clear_cookie_cache" = "Limpar cache de cookies"; +"clear_cost_cache" = "Limpar cache de custos"; +"section_notifications" = "Notificações"; +"notifications_caption" = "Aciona notificações de teste para a janela de sessão de 5 horas (esgotada/restaurada)."; +"post_depleted" = "Notificar esgotamento"; +"post_restored" = "Notificar restauração"; +"section_cli_sessions" = "Sessões da CLI"; +"cli_sessions_caption" = "Mantém sessões da CLI Codex/Claude ativas após uma verificação. Por padrão, sai assim que os dados são capturados."; +"keep_cli_sessions_alive" = "Manter sessões da CLI ativas"; +"keep_cli_sessions_alive_subtitle" = "Não encerra entre verificações (somente depuração)."; +"reset_cli_sessions" = "Reiniciar sessões da CLI"; +"section_error_simulation" = "Simulação de erro"; +"error_simulation_caption" = "Insere uma mensagem de erro falsa no card do menu para testar o layout."; +"set_menu_error" = "Definir erro do menu"; +"clear_menu_error" = "Limpar erro do menu"; +"set_cost_error" = "Definir erro de custo"; +"clear_cost_error" = "Limpar erro de custo"; +"section_cli_paths" = "Caminhos da CLI"; +"cli_paths_caption" = "Binário do Codex e camadas de PATH resolvidos; captura do PATH de login na inicialização (timeout curto)."; +"codex_binary" = "Binário do Codex"; +"claude_binary" = "Binário do Claude"; +"effective_path" = "PATH efetivo"; +"unavailable" = "Indisponível"; +"login_shell_path" = "PATH do shell de login (captura na inicialização)"; +"cleared" = "Limpo."; +"no_fetch_attempts" = "Ainda sem tentativas de busca."; +"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." = "O macOS Tahoe pode bloquear apps da barra de menus em Ajustes do Sistema → Barra de Menus → Permitir na Barra de Menus. O CodexBar está em execução, mas o macOS pode estar ocultando seu ícone. Abra os ajustes da Barra de Menus e ative o CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automático"; +"metric_pref_primary" = "Primário"; +"metric_pref_secondary" = "Secundário"; +"metric_pref_tertiary" = "Terciário"; +"metric_pref_extra_usage" = "Uso extra"; +"metric_pref_average" = "Média"; + +/* Display modes */ +"display_mode_percent" = "Porcentagem"; +"display_mode_pace" = "Ritmo"; +"display_mode_both" = "Ambos"; +"display_mode_percent_desc" = "Mostra a porcentagem restante/usada (ex.: 45%)"; +"display_mode_pace_desc" = "Mostra o indicador de ritmo (ex.: +5%)"; +"display_mode_both_desc" = "Mostra porcentagem e ritmo (ex.: 45% · +5%)"; + +/* Provider status */ +"status_operational" = "Operacional"; +"status_partial_outage" = "Falha parcial"; +"status_major_outage" = "Falha geral"; +"status_critical_issue" = "Problema crítico"; +"status_maintenance" = "Manutenção"; +"status_unknown" = "Status desconhecido"; + +/* Refresh frequency */ +"refresh_manual" = "Manual"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 min"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 min"; +"refresh_30min" = "30 min"; + +/* Cost estimation */ +"cost_header_estimated" = "Custo (estimado)"; +"cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 000000000..764a74ce8 --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,645 @@ +/* Chinese (Simplified) localization for CodexBar */ + +" providers" = ""; +"(System)" = ""; +"30d" = ""; +"A managed Codex login is already running. Wait for it to finish before adding " = ""; +"API key" = ""; +"API region" = ""; +"API token" = ""; +"API tokens" = ""; +"About" = ""; +"Account" = ""; +"Accounts" = ""; +"Accounts subtitle" = ""; +"Active" = ""; +"Add" = ""; +"Add Workspace" = "添加工作区"; +"Advanced" = ""; +"All" = ""; +"Always allow prompts" = ""; +"Animation pattern" = ""; +"Antigravity login is managed in the app" = ""; +"Applies only to the Security.framework OAuth keychain reader." = ""; +"Auto falls back to the next source if the preferred one fails." = ""; +"Auto uses API first, then falls back to CLI on auth failures." = ""; +"Auto-detect" = ""; +"Auto-refresh is off; use the menu's Refresh command." = ""; +"Auto-refresh: hourly · Timeout: 10m" = ""; +"Automatic" = ""; +"Automatic imports browser cookies and WorkOS tokens." = ""; +"Automatic imports browser cookies and local storage tokens." = ""; +"Automatic imports browser cookies for dashboard extras." = ""; +"Automatic imports browser cookies for the web API." = ""; +"Automatic imports browser cookies from Model Studio/Bailian." = ""; +"Automatic imports browser cookies from admin.mistral.ai." = ""; +"Automatic imports browser cookies from opencode.ai." = ""; +"Automatic imports browser cookies or stored sessions." = ""; +"Automatic imports browser cookies." = ""; +"Automatically imports browser session cookie." = ""; +"Automatically opens CodexBar when you start your Mac." = ""; +"Automation" = ""; +"Average (\\(label1) + \\(label2))" = ""; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = ""; +"Avoid Keychain prompts" = ""; +"Balance" = ""; +"Battery Saver" = ""; +"Bordered" = ""; +"Build" = ""; +"Built \\(buildTimestamp)" = ""; +"Buy Credits..." = ""; +"Buy Credits…" = ""; +"CLI paths" = ""; +"CLI sessions" = ""; +"Caches" = ""; +"Cancel" = "取消"; +"Check for Updates…" = ""; +"Check for updates automatically" = ""; +"Check if you like your agents having some fun up there." = ""; +"Check provider status" = ""; +"Choose Codex workspace" = "选择 Codex 工作区"; +"Choose the MiniMax host (global .io or China mainland .com)." = ""; +"Choose up to " = ""; +"Choose up to \\(Self.maxOverviewProviders) providers" = ""; +"Choose up to \\(count) providers" = ""; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = ""; +"Choose which Codex account CodexBar should follow." = ""; +"Choose which window drives the menu bar percent." = ""; +"Chrome" = ""; +"Claude CLI not found" = ""; +"Claude binary" = ""; +"Claude cookies" = ""; +"Claude login failed" = ""; +"Claude login timed out" = ""; +"Close" = ""; +"Code review" = ""; +"Codex CLI not found" = ""; +"Codex account login already running" = ""; +"Codex binary" = ""; +"Codex login failed" = ""; +"Codex login timed out" = ""; +"CodexBar Lifecycle Keepalive" = ""; +"CodexBar could not read managed account storage. " = ""; +"Configure…" = ""; +"Connected" = ""; +"Controls how much detail is logged." = ""; +"Cookie header" = ""; +"Cookie source" = ""; +"Cookie: ..." = ""; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = ""; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = ""; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = ""; +"Cookie: …" = ""; +"CopilotDeviceFlow" = ""; +"Cost" = ""; +"Could not add Codex account" = ""; +"Could not open Terminal for Gemini" = ""; +"Could not start claude /login" = ""; +"Could not start codex login" = ""; +"Could not switch system account" = ""; +"Credits" = ""; +"Credits history" = ""; +"Cursor login failed" = ""; +"Custom" = ""; +"Custom Path" = ""; +"Daily Routines" = ""; +"Debug" = ""; +"Default" = ""; +"Designs" = ""; +"Disable Keychain access" = ""; +"Disabled" = ""; +"Disconnected" = ""; +"Display" = ""; +"Display mode" = ""; +"Display reset times as absolute clock values instead of countdowns." = ""; +"Done" = ""; +"Effective 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/Antigravity, surfacing incidents in the icon and menu." = ""; +"General" = ""; +"GitHub" = ""; +"GitHub Copilot Login" = "GitHub Copilot 登录"; +"GitHub Login" = ""; +"Hide details" = "隐藏详情"; +"Hide personal information" = ""; +"Historical tracking" = ""; +"How often CodexBar polls providers in the background." = ""; +"Inactive" = ""; +"Install CLI" = ""; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = ""; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = ""; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = ""; +"JetBrains AI is ready" = "JetBrains AI 已就绪"; +"JetBrains IDE" = ""; +"Keep CLI sessions alive" = ""; +"Keyboard shortcut" = ""; +"Keychain access" = ""; +"Keychain prompt policy" = ""; +"Last \\(name) fetch failed:" = ""; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = ""; +"Last attempt" = ""; +"Link" = ""; +"Loading animations" = ""; +"Loading…" = ""; +"Local" = ""; +"Logging" = ""; +"Login failed" = ""; +"Login shell PATH (startup capture)" = ""; +"Login timed out" = ""; +"MCP details" = ""; +"Managed Codex accounts unavailable" = ""; +"Managed account storage is unreadable. Live account access is still available, " = ""; +"Manual" = ""; +"May your tokens never run out—keep agent limits in view." = ""; +"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." = ""; +"No JetBrains IDE detected" = ""; +"No cost 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 usage breakdown data." = ""; +"None" = ""; +"Notifications" = ""; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = ""; +"OK" = "确定"; +"Obscure email addresses in the menu bar and menu UI." = ""; +"Off" = ""; +"Off-peak" = ""; +"Off-peak · peak in \\(self.formatDuration(minutes: minutes))" = ""; +"Offline" = ""; +"On" = ""; +"Online" = ""; +"Only on user action" = ""; +"Open" = ""; +"Open API Keys" = ""; +"Open Amp Settings" = ""; +"Open Antigravity to sign in, then refresh CodexBar." = ""; +"Open Browser" = "打开浏览器"; +"Open Coding Plan" = ""; +"Open Console" = ""; +"Open Dashboard" = ""; +"Open Mistral Admin" = ""; +"Open Ollama Settings" = ""; +"Open Terminal" = "打开终端"; +"Open Usage Page" = ""; +"Open Warp API Key Guide" = ""; +"Open menu" = ""; +"Open token file" = ""; +"OpenAI cookies" = ""; +"OpenAI web extras" = ""; +"Option A" = ""; +"Option B" = ""; +"Optional override if workspace lookup fails." = ""; +"Options" = ""; +"Override auto-detection with a custom IDE base path" = ""; +"Overview" = ""; +"Overview rows always follow provider order." = ""; +"Overview tab providers" = ""; +"Paste API key…" = ""; +"Paste API token…" = ""; +"Paste key…" = ""; +"Paste sessionKey or OAuth token…" = ""; +"Paste the Cookie header from a request to admin.mistral.ai. " = ""; +"Paste token…" = ""; +"Peak · ends in \\(self.formatDuration(minutes: remaining))" = ""; +"Personal" = ""; +"Picker" = ""; +"Picker subtitle" = ""; +"Placeholder" = ""; +"Plan" = ""; +"Play full-screen confetti when weekly usage resets." = ""; +"Polls OpenAI/Claude status pages and Google Workspace for " = ""; +"Prevents any Keychain access while enabled." = ""; +"Primary (API key limit)" = ""; +"Primary (\\(label))" = ""; +"Primary (\\(metadata.sessionLabel))" = ""; +"Probe logs" = ""; +"Progress bars fill as you consume quota (instead of showing remaining)." = ""; +"Provider" = ""; +"Providers" = ""; +"Quit CodexBar" = ""; +"Random (default)" = ""; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = ""; +"Refresh" = ""; +"Refresh cadence" = ""; +"Remote" = ""; +"Remove" = ""; +"Remove Codex account?" = ""; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = ""; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = ""; +"Remove selected account" = ""; +"Replace critter bars with provider branding icons and a percentage." = ""; +"Replay selected animation" = ""; +"Requires authentication via GitHub Device Flow." = ""; +"Resets: \\(reset)" = ""; +"Rolling five-hour limit" = ""; +"Search hourly" = ""; +"Secondary (\\(label))" = ""; +"Secondary (\\(metadata.weeklyLabel))" = ""; +"Select a provider" = ""; +"Select the IDE to monitor" = ""; +"Session quota notifications" = ""; +"Session tokens" = ""; +"Settings" = ""; +"Show Codex Credits and Claude Extra usage sections in the menu." = ""; +"Show Debug Settings" = ""; +"Show all token accounts" = ""; +"Show cost summary" = ""; +"Show credits + extra usage" = ""; +"Show details" = "显示详情"; +"Show most-used provider" = ""; +"Show peak hours indicator" = ""; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = ""; +"Show reset time as clock" = ""; +"Show usage as used" = ""; +"Show whether Claude is in peak usage hours." = ""; +"Sign in via button below" = ""; +"Skip teardown between probes (debug-only)." = ""; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = ""; +"Start at Login" = ""; +"Status" = ""; +"Store Claude sessionKey cookies or OAuth access tokens." = ""; +"Store multiple Abacus AI Cookie headers." = ""; +"Store multiple Augment Cookie headers." = ""; +"Store multiple Cursor Cookie headers." = ""; +"Store multiple Factory Cookie headers." = ""; +"Store multiple MiniMax Cookie headers." = ""; +"Store multiple Mistral Cookie headers." = ""; +"Store multiple Ollama Cookie headers." = ""; +"Store multiple OpenCode Cookie headers." = ""; +"Store multiple OpenCode Go Cookie headers." = ""; +"Stored in the CodexBar config file." = ""; +"Stored in ~/.codexbar/config.json. " = ""; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = ""; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = ""; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = ""; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = ""; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = ""; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = ""; +"Subscription Utilization" = ""; +"Surprise me" = "给我惊喜"; +"Switcher shows icons" = ""; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = ""; +"System" = ""; +"Temporarily shows the loading animation after the next refresh." = ""; +"Tertiary (\\(label))" = ""; +"Tertiary (\\(tertiaryTitle))" = ""; +"The default Codex account on this Mac." = ""; +"Toggle" = ""; +"Toggle subtitle" = ""; +"Token" = ""; +"Trigger the menu bar menu from anywhere." = ""; +"True" = ""; +"Twitter" = ""; +"Unsupported" = ""; +"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)." = ""; +"Use a single menu bar icon with a provider switcher." = ""; +"Use international or China mainland console gateways for quota fetches." = ""; +"Version" = ""; +"Version \\(self.versionString)" = ""; +"Version \\(version)" = ""; +"Version \\(versionString)" = ""; +"Vertex AI Login" = "Vertex AI 登录"; +"Wait for the current managed Codex login to finish before adding another account." = ""; +"Waiting for Authentication..." = "等待认证…"; +"Website" = ""; +"Weekly limit confetti" = ""; +"Weekly token limit" = ""; +"Weekly usage" = ""; +"Weekly usage unavailable for this account." = "此账户的每周用量不可用。"; +"Window: \\(window)" = ""; +"Write logs to \\(self.fileLogPath) for debugging." = "将日志写入 \\(self.fileLogPath) 用于调试。"; +"Yes" = ""; +"\\(detail.modelCode): \\(usage)" = ""; +"\\(name): \\(truncated)" = ""; +"\\(name): \\(updated) · 30d \\(cost)" = ""; +"\\(name): fetching…\\(elapsed)" = ""; +"\\(name): last attempt \\(when)" = ""; +"\\(name): no data yet" = ""; +"\\(name): unsupported" = ""; +"all browsers" = ""; +"available again." = ""; +"built_format" = "构建于 %@"; +"copilot_complete_in_browser" = ""; +"copilot_device_code" = "设备代码已复制到剪贴板:%1$@ + +请在以下地址验证:%2$@"; +"copilot_device_code_copied" = ""; +"copilot_verify_at" = ""; +"copilot_waiting_text" = "请在浏览器中完成登录。 +完成后此窗口将自动关闭。"; +"copilot_window_closes_auto" = ""; +"cost_status_error" = "%@:%@"; +"cost_status_fetching" = "%1$@:获取中…%2$@"; +"cost_status_last_attempt" = "%1$@:上次尝试 %2$@"; +"cost_status_no_data" = "%@:暂无数据"; +"cost_status_snapshot" = "%1$@:%2$@ · 30天 %3$@"; +"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" = "上次获取 %1$@ 失败:"; +"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 发现了 %1$@ 的多个工作区。请选择要添加的工作区。"; +"off_peak" = "非高峰"; +"off_peak_peak_in" = "非高峰 · %@ 后进入高峰"; +"ory_session_…=…; csrftoken=…" = ""; +"overview_choose_providers" = "选择最多 %1$@ 个提供商"; +"peak_ends_in" = "高峰 · %@ 后结束"; +"remove_account_message" = "从 CodexBar 中移除 %@?其托管的 Codex 主目录将被删除。"; +"version_format" = "版本 %@"; +"vertex_ai_login_instructions" = "要使用 Vertex AI 跟踪,您需要通过 Google Cloud 进行认证。 + +1. 打开终端 +2. 运行:gcloud auth application-default login +3. 按照浏览器提示登录 +4. 设置项目:gcloud config set project PROJECT_ID + +是否现在打开终端?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = ""; +"© 2026 Peter Steinberger. MIT License." = ""; + +/* General Pane */ +"section_system" = "系统"; +"section_usage" = "用量"; +"section_automation" = "自动化"; +"language_title" = "语言"; +"language_subtitle" = "更改显示语言。需要重启应用才能完全生效。"; +"language_system" = "跟随系统"; +"language_english" = "English"; +"language_chinese_simplified" = "简体中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"start_at_login_title" = "开机启动"; +"start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; +"show_cost_summary" = "显示费用摘要"; +"show_cost_summary_subtitle" = "读取本地使用日志。在菜单中显示今天及最近30天的费用。"; +"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_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 titles */ +"tab_general" = "通用"; +"tab_providers" = "提供商"; +"tab_display" = "显示"; +"tab_advanced" = "高级"; +"tab_about" = "关于"; +"tab_debug" = "调试"; + +/* Providers Pane */ +"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`,然后重试。"; +"managed_login_missing_email" = "Codex 登录已完成,但无法获取账户邮箱。请在确认账户已完全登录后重试。"; +"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 Key 额度。"; +"automatic" = "自动"; +"primary_api_key_limit" = "主要(API 密钥限制)"; + +/* Display Pane */ +"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" = "配置配额警告后,在用量条上绘制阈值刻度标记。"; +"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" = "显示所有令牌账户"; +"show_all_token_accounts_subtitle" = "在菜单中堆叠令牌账户(否则显示账户切换栏)。"; +"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" = "未选择提供商"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "快捷键"; +"open_menu_shortcut_title" = "打开菜单"; +"open_menu_shortcut_subtitle" = "从任意位置触发菜单栏菜单。"; +"install_cli" = "安装 CLI"; +"install_cli_subtitle" = "将 CodexBarCLI 符号链接到 /usr/local/bin 和 /opt/homebrew/bin 作为 codexbar。"; +"cli_not_found" = "在应用包中未找到 CodexBarCLI。"; +"no_writable_bin_dirs" = "未找到可写的 bin 目录。"; +"show_debug_settings_title" = "显示调试设置"; +"show_debug_settings_subtitle" = "在调试标签中显示故障排除工具。"; +"surprise_me_title" = "给我惊喜"; +"surprise_me_subtitle" = "看看你是否喜欢你的智能体在上面找点乐子。"; +"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" = "禁用所有钥匙串读写。浏览器 cookie 导入不可用;在提供商中手动粘贴 Cookie 标头。"; +"disable_keychain_access_title" = "禁用钥匙串访问"; +"disable_keychain_access_subtitle" = "启用时阻止任何钥匙串访问。"; + +/* About Pane */ +"about_tagline" = "愿你的令牌永不耗尽——时刻关注智能体限制。"; +"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 License."; + +/* Debug Pane */ +"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" = "加载解析转储"; +"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 preferences */ +"metric_pref_automatic" = "自动"; +"metric_pref_primary" = "主要"; +"metric_pref_secondary" = "次要"; +"metric_pref_tertiary" = "第三"; +"metric_pref_extra_usage" = "额外用量"; +"metric_pref_average" = "平均"; + +/* Display modes */ +"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%)"; + +/* Provider status */ +"status_operational" = "正常运行"; +"status_partial_outage" = "部分中断"; +"status_major_outage" = "重大中断"; +"status_critical_issue" = "严重问题"; +"status_maintenance" = "维护中"; +"status_unknown" = "状态未知"; + +/* Refresh frequency */ +"refresh_manual" = "手动"; +"refresh_1min" = "1 分钟"; +"refresh_2min" = "2 分钟"; +"refresh_5min" = "5 分钟"; +"refresh_15min" = "15 分钟"; +"refresh_30min" = "30 分钟"; + +/* Additional keys */ +"not_found" = "未找到"; + +/* Cost estimation */ +"cost_header_estimated" = "费用(估算)"; +"cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 962b5a61f..5d295b7fc 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation @preconcurrency import UserNotifications @@ -8,6 +9,25 @@ enum SessionQuotaTransition: Equatable { case restored } +struct QuotaWarningEvent: Equatable { + let window: QuotaWarningWindow + let threshold: Int + let currentRemaining: Double + let accountDisplayName: String? + + init( + window: QuotaWarningWindow, + threshold: Int, + currentRemaining: Double, + accountDisplayName: String? = nil) + { + self.window = window + self.threshold = threshold + self.currentRemaining = currentRemaining + self.accountDisplayName = accountDisplayName + } +} + enum SessionQuotaNotificationLogic { static let depletedThreshold: Double = 0.0001 @@ -29,9 +49,60 @@ enum SessionQuotaNotificationLogic { } } +enum QuotaWarningNotificationLogic { + static func notificationCopy( + providerName: String, + window: QuotaWarningWindow, + threshold: Int, + currentRemaining: Double, + accountDisplayName: String? = nil) -> (title: String, body: String) + { + let windowLabel = window.displayName + 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.") + } + + static func crossedThreshold( + previousRemaining: Double?, + currentRemaining: Double, + thresholds: [Int], + alreadyFired: Set) -> Int? + { + let sanitized = QuotaWarningThresholds.active(thresholds) + let eligible = sanitized.filter { threshold in + currentRemaining <= Double(threshold) && !alreadyFired.contains(threshold) + } + guard !eligible.isEmpty else { return nil } + + if let previousRemaining { + let crossed = eligible.filter { previousRemaining > Double($0) } + return crossed.min() + } + + return eligible.min() + } + + static func firedThresholdsAfterWarning(threshold: Int, thresholds: [Int]) -> Set { + Set(QuotaWarningThresholds.active(thresholds).filter { $0 >= threshold }) + } + + static func thresholdsToClear(currentRemaining: Double, alreadyFired: Set) -> Set { + Set(alreadyFired.filter { currentRemaining > Double($0) }) + } + + private static func percentText(_ value: Double) -> String { + "\(Int(min(100, max(0, value)).rounded()))%" + } +} + @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) } @MainActor @@ -60,4 +131,28 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool = true) { + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let threshold = event.threshold + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: providerName, + window: event.window, + threshold: threshold, + currentRemaining: event.currentRemaining, + accountDisplayName: event.accountDisplayName) + let idPrefix = "quota-warning-\(provider.rawValue)-\(event.window.rawValue)-\(threshold)" + self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) + if soundEnabled { + (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() + } + NotificationCenter.default.post( + name: .codexbarQuotaWarningDidPost, + object: QuotaWarningPostedEvent( + provider: provider, + window: event.window, + threshold: threshold, + postedAt: Date())) + AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) + } } diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index 8195200e0..e4ef5d2aa 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -6,6 +6,84 @@ extension SettingsStore { self.configSnapshot.providerConfig(for: provider) } + func quotaWarningConfig(for provider: UsageProvider) -> QuotaWarningConfig { + self.configSnapshot.providerConfig(for: provider)?.quotaWarnings ?? QuotaWarningConfig() + } + + func resolvedQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + self.quotaWarningConfig(for: provider).thresholds( + for: window, + global: self.quotaWarningThresholds(window)) + } + + func quotaWarningEnabled(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { + self.quotaWarningConfig(for: provider).isEnabled( + for: window, + global: self.quotaWarningWindowEnabled(window)) + } + + func hasQuotaWarningOverride(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { + self.quotaWarningConfig(for: provider).hasOverride(for: window) + } + + func setQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow, thresholds: [Int]?) { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + + func setQuotaWarningOverride( + provider: UsageProvider, + window: QuotaWarningWindow, + thresholds: [Int]?, + enabled: Bool?) + { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.enabled = enabled + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.enabled = enabled + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + + func setQuotaWarningWindowEnabled(provider: UsageProvider, window: QuotaWarningWindow, enabled: Bool?) { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.enabled = enabled + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.enabled = enabled + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { get { Dictionary(uniqueKeysWithValues: self.configSnapshot.providers.compactMap { entry in diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ab32e5797..ea1d1d619 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -103,6 +103,84 @@ extension SettingsStore { } } + var quotaWarningNotificationsEnabled: Bool { + get { self.defaultsState.quotaWarningNotificationsEnabled } + set { + self.defaultsState.quotaWarningNotificationsEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningNotificationsEnabled") + } + } + + var quotaWarningThresholds: [Int] { + get { QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningThresholdsRaw) } + set { + let sanitized = QuotaWarningThresholds.sanitized(newValue) + self.defaultsState.quotaWarningThresholdsRaw = sanitized + self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized + self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningThresholds") + self.userDefaults.set(sanitized, forKey: "quotaWarningSessionThresholds") + self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") + } + } + + func quotaWarningThresholds(_ window: QuotaWarningWindow) -> [Int] { + switch window { + case .session: + QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningSessionThresholdsRaw) + case .weekly: + QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningWeeklyThresholdsRaw) + } + } + + func setQuotaWarningThresholds(_ window: QuotaWarningWindow, thresholds: [Int]) { + let sanitized = QuotaWarningThresholds.sanitized(thresholds) + switch window { + case .session: + self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningSessionThresholds") + case .weekly: + self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") + } + } + + func quotaWarningWindowEnabled(_ window: QuotaWarningWindow) -> Bool { + switch window { + case .session: + self.defaultsState.quotaWarningSessionEnabled + case .weekly: + self.defaultsState.quotaWarningWeeklyEnabled + } + } + + func setQuotaWarningWindowEnabled(_ window: QuotaWarningWindow, enabled: Bool) { + switch window { + case .session: + self.defaultsState.quotaWarningSessionEnabled = enabled + self.userDefaults.set(enabled, forKey: "quotaWarningSessionEnabled") + case .weekly: + self.defaultsState.quotaWarningWeeklyEnabled = enabled + self.userDefaults.set(enabled, forKey: "quotaWarningWeeklyEnabled") + } + } + + var quotaWarningSoundEnabled: Bool { + get { self.defaultsState.quotaWarningSoundEnabled } + set { + self.defaultsState.quotaWarningSoundEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningSoundEnabled") + } + } + + var quotaWarningMarkersVisible: Bool { + get { self.defaultsState.quotaWarningMarkersVisible } + set { + self.defaultsState.quotaWarningMarkersVisible = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningMarkersVisible") + } + } + var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { @@ -119,6 +197,14 @@ extension SettingsStore { } } + var providerChangelogLinksEnabled: Bool { + get { self.defaultsState.providerChangelogLinksEnabled } + set { + self.defaultsState.providerChangelogLinksEnabled = newValue + self.userDefaults.set(newValue, forKey: "providerChangelogLinksEnabled") + } + } + var menuBarShowsBrandIconWithPercent: Bool { get { self.defaultsState.menuBarShowsBrandIconWithPercent } set { @@ -144,14 +230,36 @@ extension SettingsStore { set { self.menuBarDisplayModeRaw = newValue.rawValue } } - var showAllTokenAccountsInMenu: Bool { - get { self.defaultsState.showAllTokenAccountsInMenu } + private var kiroMenuBarDisplayModeRaw: String? { + get { self.defaultsState.kiroMenuBarDisplayModeRaw } set { - self.defaultsState.showAllTokenAccountsInMenu = newValue - self.userDefaults.set(newValue, forKey: "showAllTokenAccountsInMenu") + self.defaultsState.kiroMenuBarDisplayModeRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "kiroMenuBarDisplayMode") + } else { + self.userDefaults.removeObject(forKey: "kiroMenuBarDisplayMode") + } } } + var kiroMenuBarDisplayMode: KiroMenuBarDisplayMode { + get { KiroMenuBarDisplayMode(rawValue: self.kiroMenuBarDisplayModeRaw ?? "") ?? .automatic } + set { self.kiroMenuBarDisplayModeRaw = newValue.rawValue } + } + + var multiAccountMenuLayout: MultiAccountMenuLayout { + get { MultiAccountMenuLayout(rawValue: self.defaultsState.multiAccountMenuLayoutRaw) ?? .segmented } + set { + self.defaultsState.multiAccountMenuLayoutRaw = newValue.rawValue + self.userDefaults.set(newValue.rawValue, forKey: "multiAccountMenuLayout") + } + } + + var showAllTokenAccountsInMenu: Bool { + get { self.multiAccountMenuLayout == .stacked } + set { self.multiAccountMenuLayout = newValue ? .stacked : .segmented } + } + var historicalTrackingEnabled: Bool { get { self.defaultsState.historicalTrackingEnabled } set { @@ -257,6 +365,14 @@ extension SettingsStore { } } + var claudePeakHoursEnabled: Bool { + get { self.defaultsState.claudePeakHoursEnabled } + set { + self.defaultsState.claudePeakHoursEnabled = newValue + self.userDefaults.set(newValue, forKey: "claudePeakHoursEnabled") + } + } + var showOptionalCreditsAndExtraUsage: Bool { get { self.defaultsState.showOptionalCreditsAndExtraUsage } set { @@ -287,6 +403,17 @@ extension SettingsStore { } } + var providerStorageFootprintsEnabled: Bool { + get { self.defaultsState.providerStorageFootprintsEnabled } + set { + self.defaultsState.providerStorageFootprintsEnabled = newValue + self.userDefaults.set(newValue, forKey: "providerStorageFootprintsEnabled") + CodexBarLog.logger(LogCategories.settings).info( + "Provider storage footprints updated", + metadata: ["enabled": newValue ? "1" : "0"]) + } + } + var jetbrainsIDEBasePath: String { get { self.defaultsState.jetbrainsIDEBasePath } set { @@ -497,6 +624,27 @@ extension SettingsStore { } } + var appLanguage: String { + get { self.defaultsState.appLanguageRaw ?? "" } + set { + let stored = newValue.isEmpty ? nil : newValue + self.defaultsState.appLanguageRaw = stored + if let stored { + self.userDefaults.set(stored, forKey: "appLanguage") + if self.userDefaults !== UserDefaults.standard { + UserDefaults.standard.set(stored, forKey: "appLanguage") + } + UserDefaults.standard.set([stored], forKey: "AppleLanguages") + } else { + self.userDefaults.removeObject(forKey: "appLanguage") + if self.userDefaults !== UserDefaults.standard { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + } + var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 73786c47f..38b711af1 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -11,13 +11,23 @@ extension SettingsStore { _ = self.debugKeepCLISessionsAlive _ = self.statusChecksEnabled _ = self.sessionQuotaNotificationsEnabled + _ = self.quotaWarningNotificationsEnabled + _ = self.quotaWarningThresholds + _ = self.quotaWarningThresholds(.session) + _ = self.quotaWarningThresholds(.weekly) + _ = self.quotaWarningWindowEnabled(.session) + _ = self.quotaWarningWindowEnabled(.weekly) + _ = self.quotaWarningSoundEnabled + _ = self.quotaWarningMarkersVisible _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute + _ = self.providerChangelogLinksEnabled _ = self.menuBarShowsBrandIconWithPercent _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode + _ = self.kiroMenuBarDisplayMode _ = self.historicalTrackingEnabled - _ = self.showAllTokenAccountsInMenu + _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled _ = self.hidePersonalInfo @@ -29,6 +39,7 @@ extension SettingsStore { _ = self.showOptionalCreditsAndExtraUsage _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled + _ = self.providerStorageFootprintsEnabled _ = self.codexUsageDataSource _ = self.codexActiveSource _ = self.claudeUsageDataSource diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index f4b136e86..ce8dacf45 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -3,6 +3,9 @@ import Foundation extension SettingsStore { func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference { + if Self.isBalanceOnlyProvider(provider) { + return .automatic + } if provider == .openrouter { let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? "" let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic @@ -28,6 +31,10 @@ extension SettingsStore { } func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) { + if Self.isBalanceOnlyProvider(provider) { + self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue + return + } if provider == .openrouter { switch preference { case .automatic, .primary: @@ -64,7 +71,7 @@ extension SettingsStore { } func menuBarMetricSupportsExtraUsage(for provider: UsageProvider) -> Bool { - provider == .cursor + provider == .cursor || provider == .claude } func menuBarMetricSupportsExtraUsage(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { @@ -96,4 +103,13 @@ extension SettingsStore { var resetTimeDisplayStyle: ResetTimeDisplayStyle { self.resetTimesShowAbsolute ? .absolute : .countdown } + + static func isBalanceOnlyProvider(_ provider: UsageProvider) -> Bool { + switch provider { + case .deepseek, .mistral, .kimik2, .moonshot: + true + default: + false + } + } } diff --git a/Sources/CodexBar/SettingsStore+ProviderDetection.swift b/Sources/CodexBar/SettingsStore+ProviderDetection.swift index b8534276c..d4efded24 100644 --- a/Sources/CodexBar/SettingsStore+ProviderDetection.swift +++ b/Sources/CodexBar/SettingsStore+ProviderDetection.swift @@ -17,14 +17,17 @@ extension SettingsStore { let claudeInstalled = BinaryLocator.resolveClaudeBinary() != nil let geminiInstalled = BinaryLocator.resolveGeminiBinary() != nil let antigravityRunning = await AntigravityStatusProbe.isRunning() + let antigravityLoggedIn = FileManager.default.fileExists( + atPath: AntigravityOAuthCredentialsStore().fileURL.path) let logger = CodexBarLog.logger(LogCategories.providerDetection) // If none installed, keep Codex enabled to match previous behavior. - let noneInstalled = !codexInstalled && !claudeInstalled && !geminiInstalled && !antigravityRunning + let noneInstalled = !codexInstalled && !claudeInstalled && !geminiInstalled && !antigravityRunning && + !antigravityLoggedIn let enableCodex = codexInstalled || noneInstalled let enableClaude = claudeInstalled let enableGemini = geminiInstalled - let enableAntigravity = antigravityRunning + let enableAntigravity = antigravityRunning || antigravityLoggedIn logger.info( "Provider detection results", @@ -33,6 +36,7 @@ extension SettingsStore { "claudeInstalled": claudeInstalled ? "1" : "0", "geminiInstalled": geminiInstalled ? "1" : "0", "antigravityRunning": antigravityRunning ? "1" : "0", + "antigravityLoggedIn": antigravityLoggedIn ? "1" : "0", ]) logger.info( "Provider detection enablement", diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 1f8a0277b..01c1387c7 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -36,11 +36,21 @@ extension SettingsStore { ]) } - func addTokenAccount(provider: UsageProvider, label: String, token: String) { + func addTokenAccount( + provider: UsageProvider, + label: String, + token: String, + externalIdentifier: String? = nil, + organizationID: String? = nil) + { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return } let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedToken.isEmpty else { return } let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedIdentifier = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedIdentifier = (trimmedIdentifier?.isEmpty ?? true) ? nil : trimmedIdentifier + let trimmedOrganizationID = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedOrganizationID = (trimmedOrganizationID?.isEmpty ?? true) ? nil : trimmedOrganizationID let existing = self.tokenAccountsData(for: provider) let accounts = existing?.accounts ?? [] let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel @@ -49,13 +59,18 @@ extension SettingsStore { label: fallbackLabel, token: trimmedToken, addedAt: Date().timeIntervalSince1970, - lastUsed: nil) + lastUsed: nil, + externalIdentifier: normalisedIdentifier, + organizationID: normalisedOrganizationID) let updated = ProviderTokenAccountData( version: existing?.version ?? 1, accounts: accounts + [account], activeIndex: accounts.count) self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) CodexBarLog.logger(LogCategories.tokenAccounts).info( @@ -66,18 +81,89 @@ extension SettingsStore { ]) } + func updateTokenAccount( + provider: UsageProvider, + accountID: UUID, + label: String? = nil, + token: String? = nil, + externalIdentifier: String?? = nil, + organizationID: String?? = nil) + { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } + + let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedToken, trimmedToken.isEmpty { return } + + let existing = data.accounts[index] + let resolvedIdentifier: String? + if let externalIdentifier { + let trimmed = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedIdentifier = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedIdentifier = existing.externalIdentifier + } + let resolvedOrganizationID: String? + if let organizationID { + let trimmed = organizationID?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedOrganizationID = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedOrganizationID = existing.organizationID + } + let updatedAccount = ProviderTokenAccount( + id: existing.id, + label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, + token: trimmedToken ?? existing.token, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed, + externalIdentifier: resolvedIdentifier, + organizationID: resolvedOrganizationID) + + var accounts = data.accounts + accounts[index] = updatedAccount + let updated = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } + } + self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + CodexBarLog.logger(LogCategories.tokenAccounts).info( + "Token account updated", + metadata: [ + "provider": provider.rawValue, + "count": "\(updated.accounts.count)", + ]) + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + let activeAccountID = data.accounts[data.clampedActiveIndex()].id + guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { - let clamped = min(max(data.activeIndex, 0), filtered.count - 1) + let nextActiveIndex = if activeAccountID != accountID, + let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID }) + { + preservedIndex + } else { + min(removedIndex, filtered.count - 1) + } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: nextActiveIndex) + } + if provider == .copilot { + entry.apiKey = nil } } CodexBarLog.logger(LogCategories.tokenAccounts).info( diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b006f6d0e..983831150 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -28,12 +28,12 @@ enum RefreshFrequency: String, CaseIterable, Identifiable { var label: String { switch self { - case .manual: "Manual" - case .oneMinute: "1 min" - case .twoMinutes: "2 min" - case .fiveMinutes: "5 min" - case .fifteenMinutes: "15 min" - case .thirtyMinutes: "30 min" + case .manual: L("refresh_manual") + case .oneMinute: L("refresh_1min") + case .twoMinutes: L("refresh_2min") + case .fiveMinutes: L("refresh_5min") + case .fifteenMinutes: L("refresh_15min") + case .thirtyMinutes: L("refresh_30min") } } } @@ -50,14 +50,60 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { self.rawValue } + var label: String { + switch self { + case .automatic: L("metric_pref_automatic") + case .primary: L("metric_pref_primary") + case .secondary: L("metric_pref_secondary") + case .tertiary: L("metric_pref_tertiary") + case .extraUsage: L("metric_pref_extra_usage") + case .average: L("metric_pref_average") + } + } +} + +enum KiroMenuBarDisplayMode: String, CaseIterable, Identifiable { + case automatic + case hidden + case creditsLeft + case percentLeft + case creditsAndPercent + case usedAndTotal + case overageCreditsWhenExhausted + case overageCostWhenExhausted + case overageCreditsAndCostWhenExhausted + + var id: String { + self.rawValue + } + var label: String { switch self { case .automatic: "Automatic" - case .primary: "Primary" - case .secondary: "Secondary" - case .tertiary: "Tertiary" - case .extraUsage: "Extra usage" - case .average: "Average" + case .hidden: "Hidden" + case .creditsLeft: "Credits left" + case .percentLeft: "Percent left" + case .creditsAndPercent: "Credits + percent" + case .usedAndTotal: "Used / total" + case .overageCreditsWhenExhausted: "Overage credits at zero" + case .overageCostWhenExhausted: "Overage cost at zero" + case .overageCreditsAndCostWhenExhausted: "Overage credits + cost at zero" + } + } +} + +enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { + case segmented + case stacked + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .segmented: L("multi_account_layout_segmented") + case .stacked: L("multi_account_layout_stacked") } } } @@ -127,7 +173,13 @@ final class SettingsStore { tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore()) { let appGroupID = AppGroupSupport.currentGroupID() - let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + let appGroupMigration: AppGroupSupport.MigrationResult + if Self.isRunningTests { + appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults) + } else { + Self.scheduleAppGroupMigration() + appGroupMigration = AppGroupSupport.MigrationResult(status: .targetUnavailable) + } let sharedDefaultsAvailable = Self.sharedDefaults != nil if !Self.isRunningTests { CodexBarLog.logger(LogCategories.settings).info( @@ -141,6 +193,11 @@ final class SettingsStore { ]) } + if userDefaults.object(forKey: "openAIWebAccessEnabled") == nil, + let legacyOpenAIWebAccess = userDefaults.object(forKey: "openAIWebAccess") as? Bool + { + userDefaults.set(legacyOpenAIWebAccess, forKey: "openAIWebAccessEnabled") + } let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil let hadExistingConfig = (try? configStore.load()) != nil let legacyStores = CodexBarConfigMigrator.LegacyStores( @@ -177,22 +234,43 @@ final class SettingsStore { self.runInitialProviderDetectionIfNeeded() self.ensureAlibabaProviderAutoEnabledIfNeeded() self.applyTokenCostDefaultIfNeeded() - if self.claudeUsageDataSource != .cli { self.claudeWebExtrasEnabled = false } - if hasStoredOpenAIWebAccessPreference { - self.openAIWebAccessEnabled = self.defaultsState.openAIWebAccessEnabled + if self.claudeUsageDataSource != .cli { + if Self.isRunningTests { + self.claudeWebExtrasEnabled = false + } else { + self.defaultsState.claudeWebExtrasEnabledRaw = false + } + } + let resolvedOpenAIWebAccessEnabled = if hasStoredOpenAIWebAccessPreference { + self.defaultsState.openAIWebAccessEnabled } else { - self.openAIWebAccessEnabled = Self.inferredInitialOpenAIWebAccessEnabled( + Self.inferredInitialOpenAIWebAccessEnabled( config: config, hadExistingConfig: hadExistingConfig) } - if Self.shouldBridgeSharedDefaults(for: userDefaults) { - Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") + if Self.isRunningTests { + self.openAIWebAccessEnabled = resolvedOpenAIWebAccessEnabled + } else { + self.defaultsState.openAIWebAccessEnabled = resolvedOpenAIWebAccessEnabled } KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess } } extension SettingsStore { + private static func scheduleAppGroupMigration() { + Task.detached(priority: .utility) { + let result = AppGroupSupport.migrateLegacyDataIfNeeded() + CodexBarLog.logger(LogCategories.settings).info( + "App group migration completed", + metadata: [ + "migrationStatus": result.status.rawValue, + "migratedSnapshot": result.copiedSnapshot ? "1" : "0", + "migratedDefaults": "\(result.copiedDefaults)", + ]) + } + } + private static func inferredInitialOpenAIWebAccessEnabled( config: CodexBarConfig, hadExistingConfig: Bool) -> Bool @@ -207,7 +285,7 @@ extension SettingsStore { let refreshDefault = userDefaults.string(forKey: "refreshFrequency") .flatMap(RefreshFrequency.init(rawValue:)) let refreshFrequency = refreshDefault ?? .fiveMinutes - if refreshDefault == nil { + if Self.isRunningTests, refreshDefault == nil { userDefaults.set(refreshFrequency.rawValue, forKey: "refreshFrequency") } let launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false @@ -219,14 +297,16 @@ extension SettingsStore { if Self.shouldBridgeSharedDefaults(for: userDefaults), let shared = Self.sharedDefaults?.object(forKey: "debugDisableKeychainAccess") as? Bool { - userDefaults.set(shared, forKey: "debugDisableKeychainAccess") + if Self.isRunningTests { + userDefaults.set(shared, forKey: "debugDisableKeychainAccess") + } return shared } return false }() let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue - if userDefaults.string(forKey: "debugLogLevel") == nil { + if Self.isRunningTests, userDefaults.string(forKey: "debugLogLevel") == nil { userDefaults.set(debugLogLevelRaw, forKey: "debugLogLevel") } let debugLoadingPatternRaw = userDefaults.string(forKey: "debugLoadingPattern") @@ -234,26 +314,31 @@ extension SettingsStore { let statusChecksEnabled = userDefaults.object(forKey: "statusChecksEnabled") as? Bool ?? true let sessionQuotaDefault = userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") as? Bool let sessionQuotaNotificationsEnabled = sessionQuotaDefault ?? true - if sessionQuotaDefault == nil { + if Self.isRunningTests, sessionQuotaDefault == nil { userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") } + let quotaWarnings = Self.loadQuotaWarningDefaults(userDefaults: userDefaults) + let quotaWarningMarkersVisibleDefault = userDefaults.object(forKey: "quotaWarningMarkersVisible") as? Bool + let quotaWarningMarkersVisible = quotaWarningMarkersVisibleDefault ?? true + if Self.isRunningTests, quotaWarningMarkersVisibleDefault == nil { + userDefaults.set(true, forKey: "quotaWarningMarkersVisible") + } let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false + let providerChangelogLinksEnabled = userDefaults.object( + forKey: "providerChangelogLinksEnabled") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue + let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") + ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false - let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false - let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] - var resolvedPreferences = storedPreferences - if resolvedPreferences.isEmpty, - let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), - let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) - { - resolvedPreferences = Dictionary( - uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) - } + let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { + let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false + return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue + }() + let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false @@ -263,15 +348,27 @@ extension SettingsStore { let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false + let claudePeakHoursEnabled = userDefaults.object(forKey: "claudePeakHoursEnabled") as? Bool ?? true let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true - if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } + if Self.isRunningTests, creditsExtrasDefault == nil { + userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") + } let openAIWebAccessDefault = userDefaults.object(forKey: "openAIWebAccessEnabled") as? Bool let openAIWebAccessEnabled = openAIWebAccessDefault ?? false - if openAIWebAccessDefault == nil { userDefaults.set(false, forKey: "openAIWebAccessEnabled") } + if Self.isRunningTests, openAIWebAccessDefault == nil { + userDefaults.set(false, forKey: "openAIWebAccessEnabled") + } let openAIWebBatterySaverDefault = userDefaults.object(forKey: "openAIWebBatterySaverEnabled") as? Bool let openAIWebBatterySaverEnabled = openAIWebBatterySaverDefault ?? false - if openAIWebBatterySaverDefault == nil { userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled") } + if Self.isRunningTests, openAIWebBatterySaverDefault == nil { + userDefaults.set(false, forKey: "openAIWebBatterySaverEnabled") + } + let providerStorageFootprintsDefault = userDefaults.object(forKey: "providerStorageFootprintsEnabled") as? Bool + let providerStorageFootprintsEnabled = providerStorageFootprintsDefault ?? false + if Self.isRunningTests, providerStorageFootprintsDefault == nil { + userDefaults.set(false, forKey: "providerStorageFootprintsEnabled") + } let jetbrainsIDEBasePath = userDefaults.string(forKey: "jetbrainsIDEBasePath") ?? "" let mergeIcons = userDefaults.object(forKey: "mergeIcons") as? Bool ?? true let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true @@ -281,6 +378,7 @@ extension SettingsStore { forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let appLanguageRaw = userDefaults.string(forKey: "appLanguage") return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -293,12 +391,22 @@ extension SettingsStore { debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, statusChecksEnabled: statusChecksEnabled, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, + quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled, + quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw, + quotaWarningSessionThresholdsRaw: quotaWarnings.sessionThresholdsRaw, + quotaWarningWeeklyThresholdsRaw: quotaWarnings.weeklyThresholdsRaw, + quotaWarningSessionEnabled: quotaWarnings.sessionEnabled, + quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, + quotaWarningSoundEnabled: quotaWarnings.soundEnabled, + quotaWarningMarkersVisible: quotaWarningMarkersVisible, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, + providerChangelogLinksEnabled: providerChangelogLinksEnabled, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, + kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, - showAllTokenAccountsInMenu: showAllTokenAccountsInMenu, + multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, costUsageEnabled: costUsageEnabled, hidePersonalInfo: hidePersonalInfo, @@ -308,16 +416,86 @@ extension SettingsStore { claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, + claudePeakHoursEnabled: claudePeakHoursEnabled, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, + providerStorageFootprintsEnabled: providerStorageFootprintsEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, mergeIcons: mergeIcons, switcherShowsIcons: switcherShowsIcons, mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + appLanguageRaw: appLanguageRaw) + } + + private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] { + let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] + if !storedPreferences.isEmpty { + return storedPreferences + } + guard let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), + let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) + else { return [:] } + return Dictionary(uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) + } + + private struct LoadedQuotaWarningDefaults { + var notificationsEnabled: Bool + var thresholdsRaw: [Int] + var sessionThresholdsRaw: [Int] + var weeklyThresholdsRaw: [Int] + var sessionEnabled: Bool + var weeklyEnabled: Bool + var soundEnabled: Bool + } + + private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults { + let notificationsEnabled = userDefaults.object(forKey: "quotaWarningNotificationsEnabled") as? Bool ?? false + let rawThresholds = userDefaults.array(forKey: "quotaWarningThresholds") as? [Int] + let thresholdsRaw = QuotaWarningThresholds.sanitized(rawThresholds ?? QuotaWarningThresholds.defaults) + if Self.isRunningTests, rawThresholds != thresholdsRaw { + userDefaults.set(thresholdsRaw, forKey: "quotaWarningThresholds") + } + let rawSessionThresholds = userDefaults.array(forKey: "quotaWarningSessionThresholds") as? [Int] + let sessionThresholdsRaw = QuotaWarningThresholds.sanitized(rawSessionThresholds ?? thresholdsRaw) + if Self.isRunningTests, rawSessionThresholds != sessionThresholdsRaw { + userDefaults.set(sessionThresholdsRaw, forKey: "quotaWarningSessionThresholds") + } + let rawWeeklyThresholds = userDefaults.array(forKey: "quotaWarningWeeklyThresholds") as? [Int] + let weeklyThresholdsRaw = QuotaWarningThresholds.sanitized(rawWeeklyThresholds ?? thresholdsRaw) + if Self.isRunningTests, rawWeeklyThresholds != weeklyThresholdsRaw { + userDefaults.set(weeklyThresholdsRaw, forKey: "quotaWarningWeeklyThresholds") + } + + let sessionDefault = userDefaults.object(forKey: "quotaWarningSessionEnabled") as? Bool + let sessionEnabled = sessionDefault ?? true + if Self.isRunningTests, sessionDefault == nil { + userDefaults.set(true, forKey: "quotaWarningSessionEnabled") + } + + let weeklyDefault = userDefaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool + let weeklyEnabled = weeklyDefault ?? true + if Self.isRunningTests, weeklyDefault == nil { + userDefaults.set(true, forKey: "quotaWarningWeeklyEnabled") + } + + let soundDefault = userDefaults.object(forKey: "quotaWarningSoundEnabled") as? Bool + let soundEnabled = soundDefault ?? true + if Self.isRunningTests, soundDefault == nil { + userDefaults.set(true, forKey: "quotaWarningSoundEnabled") + } + + return LoadedQuotaWarningDefaults( + notificationsEnabled: notificationsEnabled, + thresholdsRaw: thresholdsRaw, + sessionThresholdsRaw: sessionThresholdsRaw, + weeklyThresholdsRaw: weeklyThresholdsRaw, + sessionEnabled: sessionEnabled, + weeklyEnabled: weeklyEnabled, + soundEnabled: soundEnabled) } } @@ -377,6 +555,9 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.enabled = enabled } + if !enabled, self.selectedMenuProvider == provider { + self.selectedMenuProvider = nil + } } func rerunProviderDetection() { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a65fb45d5..f3c3d591b 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -11,12 +11,22 @@ struct SettingsDefaultsState { var debugKeepCLISessionsAlive: Bool var statusChecksEnabled: Bool var sessionQuotaNotificationsEnabled: Bool + var quotaWarningNotificationsEnabled: Bool + var quotaWarningThresholdsRaw: [Int] + var quotaWarningSessionThresholdsRaw: [Int] + var quotaWarningWeeklyThresholdsRaw: [Int] + var quotaWarningSessionEnabled: Bool + var quotaWarningWeeklyEnabled: Bool + var quotaWarningSoundEnabled: Bool + var quotaWarningMarkersVisible: Bool var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool + var providerChangelogLinksEnabled: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? + var kiroMenuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool - var showAllTokenAccountsInMenu: Bool + var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] var costUsageEnabled: Bool var hidePersonalInfo: Bool @@ -26,9 +36,11 @@ struct SettingsDefaultsState { var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool + var claudePeakHoursEnabled: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool + var providerStorageFootprintsEnabled: Bool var jetbrainsIDEBasePath: String var mergeIcons: Bool var switcherShowsIcons: Bool @@ -36,4 +48,5 @@ struct SettingsDefaultsState { var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var appLanguageRaw: String? } diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift new file mode 100644 index 000000000..ea0bb9d2d --- /dev/null +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -0,0 +1,75 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { + guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } + let accounts = self.settings.tokenAccounts(for: provider) + guard accounts.count > 1 else { return nil } + let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 + let showAll = self.settings.multiAccountMenuLayout == .stacked + let displayAccounts = showAll + ? self.store.limitedTokenAccounts(accounts, selected: self.settings.selectedTokenAccount(for: provider)) + : accounts + let snapshots = showAll + ? self.tokenAccountSnapshots(for: provider, matching: displayAccounts) + : [] + return TokenAccountMenuDisplay( + provider: provider, + accounts: displayAccounts, + snapshots: snapshots, + activeIndex: activeIndex, + layout: showAll ? .stacked : .segmented) + } + + private func tokenAccountSnapshots( + for provider: UsageProvider, + matching accounts: [ProviderTokenAccount]) -> [TokenAccountUsageSnapshot] + { + var snapshotsByID: [UUID: TokenAccountUsageSnapshot] = [:] + for snapshot in self.store.accountSnapshots[provider] ?? [] { + snapshotsByID[snapshot.account.id] = snapshot + } + return accounts.compactMap { snapshotsByID[$0.id] } + } + + func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { + guard provider == .codex else { return nil } + let projection = self.settings.codexVisibleAccountProjection + guard projection.visibleAccounts.count > 1 else { return nil } + let showAll = self.settings.multiAccountMenuLayout == .stacked + let accounts = showAll + ? self.store.limitedCodexVisibleAccounts( + projection.visibleAccounts, + snapshots: self.store.codexAccountSnapshots, + activeVisibleAccountID: projection.activeVisibleAccountID) + : projection.visibleAccounts + let snapshots = showAll ? self.codexAccountSnapshots(matching: accounts) : [] + return CodexAccountMenuDisplay( + accounts: accounts, + snapshots: snapshots, + activeVisibleAccountID: projection.activeVisibleAccountID, + layout: showAll ? .stacked : .segmented) + } + + private func codexAccountSnapshots(matching accounts: [CodexVisibleAccount]) -> [CodexAccountUsageSnapshot] { + var snapshotsByID: [String: CodexAccountUsageSnapshot] = [:] + for snapshot in self.store.codexAccountSnapshots { + snapshotsByID[snapshot.id] = snapshot + } + return accounts.compactMap { snapshotsByID[$0.id] } + } + + func stableCodexAccountMenuDisplay( + _ display: CodexAccountMenuDisplay?, + menu: NSMenu, + provider: UsageProvider) -> CodexAccountMenuDisplay? + { + guard provider == .codex else { return display } + guard display == nil else { return display } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return display } + guard menu.items.contains(where: { $0.view is CodexAccountSwitcherView }) else { return display } + guard let previous = self.lastCodexAccountMenuDisplay, previous.showSwitcher else { return display } + return previous + } +} diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index e9fcf6f66..65537b3b4 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,21 +1,43 @@ import AppKit import CodexBarCore -extension StatusItemController { +extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus - func refreshStore(forceTokenUsage: Bool) { + func refreshStore(forceTokenUsage: Bool, refreshOpenMenusWhenComplete: Bool = true) { Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() + } } } } + func refreshOpenMenusAfterExplicitStoreAction() { + self.invalidateMenus(refreshOpenMenus: true) + } + @objc func refreshNow() { self.refreshStore(forceTokenUsage: true) } + nonisolated func performPersistentRefreshAction() { + Task { @MainActor [weak self] in + self?.refreshNow() + } + } + + nonisolated func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { + Task { @MainActor [weak self] in + self?.navigateProviderSwitcher(direction) + } + } + @objc func refreshAugmentSession() { Task { await self.store.forceRefreshAugmentSession() @@ -23,11 +45,12 @@ extension StatusItemController { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: false) } + self.refreshOpenMenusAfterExplicitStoreAction() } } @objc func installUpdate() { - self.updater.checkForUpdates(nil) + self.updater.installUpdate() } @objc func openDashboard() { @@ -43,6 +66,13 @@ extension StatusItemController { if provider == .alibaba { return self.settings.alibabaCodingPlanAPIRegion.dashboardURL } + if provider == .minimax { + return self.settings.minimaxAPIRegion.dashboardURL + } + + if provider == .opencodego { + return self.settings.opencodegoDashboardURL + } let meta = self.store.metadata(for: provider) let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { @@ -74,13 +104,22 @@ extension StatusItemController { self.creditsPurchaseWindow = controller } - private static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? { + static func sanitizedCreditsPurchaseURL(_ raw: String?) -> String? { guard let raw, let url = URL(string: raw) else { return nil } - guard let host = url.host?.lowercased(), host.contains("chatgpt.com") else { return nil } - let path = url.path.lowercased() + guard Self.isAllowedChatGPTPurchaseHost(url) else { return nil } + let pathComponents = url.pathComponents.map { $0.lowercased() } let allowed = ["settings", "usage", "billing", "credits"] - guard allowed.contains(where: { path.contains($0) }) else { return nil } - return url.absoluteString + guard pathComponents.contains(where: { allowed.contains($0) }) else { return nil } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.query = nil + components?.fragment = nil + return components?.url?.absoluteString ?? url.absoluteString + } + + private static func isAllowedChatGPTPurchaseHost(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "https" else { return false } + guard let host = url.host?.lowercased() else { return false } + return host == "chatgpt.com" || host.hasSuffix(".chatgpt.com") } @objc func openStatusPage() { @@ -94,6 +133,16 @@ extension StatusItemController { NSWorkspace.shared.open(url) } + @objc func openChangelog() { + let preferred = self.lastMenuProvider + ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) + + let provider = preferred ?? .codex + let meta = self.store.metadata(for: provider) + guard let urlString = meta.changelogURL, let url = URL(string: urlString) else { return } + NSWorkspace.shared.open(url) + } + @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" Self.openTerminal(command: command) @@ -157,25 +206,18 @@ extension StatusItemController { let rawProvider = sender.representedObject as? String let provider = rawProvider.flatMap(UsageProvider.init(rawValue:)) ?? self.lastMenuProvider ?? .codex self.loginLogger.info("Switch Account tapped", metadata: ["provider": provider.rawValue]) + self.startLoginFlow(provider: provider) + } - self.loginTask = Task { @MainActor [weak self] in - guard let self else { return } - defer { - self.activeLoginProvider = nil - self.loginTask = nil - } - self.activeLoginProvider = provider - self.loginPhase = .requesting - self.loginLogger.info("Starting login task", metadata: ["provider": provider.rawValue]) - - let shouldRefresh = await self.runLoginFlow(provider: provider) - if shouldRefresh { - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh() - } - self.loginLogger.info("Triggered refresh after login", metadata: ["provider": provider.rawValue]) - } + func runLoginFlowFromSettings(provider: UsageProvider) async { + guard self.loginTask == nil else { + self.loginLogger.info( + "Settings login tap ignored: login already in-flight", + metadata: ["provider": provider.rawValue]) + return } + self.startLoginFlow(provider: provider) + await self.loginTask?.value } @objc func showSettingsGeneral() { @@ -187,6 +229,10 @@ extension StatusItemController { } func openMenuFromShortcut() { + if self.closeOpenMenusFromShortcutIfNeeded() { + return + } + if self.shouldMergeIcons { self.statusItem.button?.performClick(nil) return @@ -198,6 +244,18 @@ extension StatusItemController { item.button?.performClick(nil) } + @discardableResult + func closeOpenMenusFromShortcutIfNeeded() -> Bool { + guard !self.openMenus.isEmpty else { return false } + + let menus = Array(self.openMenus.values) + for menu in menus { + menu.cancelTrackingWithoutAnimation() + self.forgetClosedMenu(menu) + } + return true + } + func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { let item: NSStatusItem = if self.shouldMergeIcons { self.statusItem @@ -272,6 +330,27 @@ extension StatusItemController { return .codex } + private func startLoginFlow(provider: UsageProvider) { + self.loginTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + self.activeLoginProvider = nil + self.loginTask = nil + } + self.activeLoginProvider = provider + self.loginPhase = .requesting + self.loginLogger.info("Starting login task", metadata: ["provider": provider.rawValue]) + + let shouldRefresh = await self.runLoginFlow(provider: provider) + if shouldRefresh { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refresh() + } + self.loginLogger.info("Triggered refresh after login", metadata: ["provider": provider.rawValue]) + } + } + } + func presentCodexLoginResult(_ result: CodexLoginRunner.Result) { guard let info = CodexLoginAlertPresentation.alertInfo(for: result) else { return } self.presentLoginAlert(title: info.title, message: info.message) @@ -288,10 +367,12 @@ extension StatusItemController { } else if let error = error as? ManagedCodexAccountServiceError { let message = switch error { case .loginFailed: - "Managed Codex login did not complete. Try again after finishing the browser login flow." + 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)" } @@ -352,11 +433,31 @@ extension StatusItemController { } } + func describe(_ outcome: AntigravityLoginRunner.Result.Outcome) -> String { + switch outcome { + case let .success(email): + "success(email: \(email ?? "nil"))" + case .cancelled: + "cancelled" + case .timedOut: + "timedOut" + case let .launchFailed(message): + "launchFailed(\(message))" + case let .failed(message): + "failed(\(message))" + } + } + func presentGeminiLoginResult(_ result: GeminiLoginRunner.Result) { guard let info = Self.geminiLoginAlertInfo(for: result) else { return } self.presentLoginAlert(title: info.title, message: info.message) } + func presentAntigravityLoginResult(_ result: AntigravityLoginRunner.Result) { + guard let info = Self.antigravityLoginAlertInfo(for: result) else { return } + self.presentLoginAlert(title: info.title, message: info.message) + } + struct LoginAlertInfo: Equatable { let title: String let message: String @@ -375,6 +476,23 @@ extension StatusItemController { } } + nonisolated static func antigravityLoginAlertInfo(for result: AntigravityLoginRunner.Result) -> LoginAlertInfo? { + switch result.outcome { + case .success, .cancelled: + nil + case .timedOut: + LoginAlertInfo( + title: "Antigravity login timed out", + message: "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)") + case let .failed(message): + LoginAlertInfo(title: "Antigravity login failed", message: message) + } + } + func presentLoginAlert(title: String, message: String) { let alert = NSAlert() alert.messageText = title diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index bed72a824..0e31ac0ee 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -6,6 +6,9 @@ extension StatusItemController { private static let loadingPercentEpsilon = 0.0001 private static let blinkActiveTickInterval: Duration = .milliseconds(75) private static let blinkIdleFallbackInterval: Duration = .seconds(1) + static let loadingAnimationFPS: Double = 30.0 + static let loadingAnimationPhaseIncrement: Double = 2.7 / StatusItemController.loadingAnimationFPS + private static let loadingAnimationMaxContinuousDuration: TimeInterval = 30.0 func needsMenuBarIconAnimation() -> Bool { if self.shouldMergeIcons { @@ -16,6 +19,9 @@ extension StatusItemController { } func updateBlinkingState() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // During the loading animation, blink ticks can overwrite the animated menu bar icon and cause flicker. if self.needsMenuBarIconAnimation() { self.stopBlinking() @@ -159,8 +165,7 @@ extension StatusItemController { } } if mergeIcons { - let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil - self.applyIcon(phase: phase) + self.applyIcon(phase: nil) } } @@ -217,7 +222,6 @@ extension StatusItemController { return false } - // swiftlint:disable function_body_length @discardableResult func applyIcon(phase: Double?) -> Bool { guard let button = self.statusItem.button else { return false } @@ -227,6 +231,7 @@ extension StatusItemController { let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let primaryProvider = self.primaryProviderForUnifiedIcon() let snapshot = self.store.snapshot(for: primaryProvider) + let warningFlash = self.quotaWarningFlashActive(provider: primaryProvider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". @@ -320,40 +325,17 @@ extension StatusItemController { "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", "text=\(displayText ?? "nil")", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { return true } - self.setButtonImage(brand, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) return false } - if Self.shouldUseOpenRouterBrandFallback(provider: primaryProvider, snapshot: snapshot), - let brand = ProviderBrandIcon.image(for: primaryProvider) - { - let signature = [ - "mode=openRouterFallback", - "provider=\(primaryProvider.rawValue)", - "style=\(String(describing: style))", - "primary=\(debugDouble(primary))", - "weekly=\(debugDouble(weekly))", - "credits=\(debugDouble(credits))", - "stale=\(stale ? "1" : "0")", - "status=\(statusIndicator.rawValue)", - "anim=\(needsAnimation ? "1" : "0")", - ].joined(separator: "|") - if self.shouldSkipMergedIconRender(signature) { - return true - } - self.setButtonTitle(nil, for: button) - self.setButtonImage( - Self.brandImageWithStatusOverlay(brand: brand, statusIndicator: statusIndicator), - for: button) - return false - } - self.setButtonTitle(nil, for: button) if let morphProgress { let signature = [ @@ -362,13 +344,14 @@ extension StatusItemController { "style=\(String(describing: style))", "morph=\(debugDouble(morphProgress))", "status=\(statusIndicator.rawValue)", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { return true } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { let signature = [ "mode=icon", @@ -382,6 +365,7 @@ extension StatusItemController { "blink=\(debugDouble(Double(blink)))", "wiggle=\(debugDouble(Double(wiggle)))", "tilt=\(debugDouble(Double(tilt)))", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { @@ -397,13 +381,11 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } return false } - // swiftlint:enable function_body_length - private func shouldSkipMergedIconRender(_ signature: String) -> Bool { guard self.shouldMergeIcons else { self.lastAppliedMergedIconRenderSignature = signature @@ -416,35 +398,45 @@ extension StatusItemController { return false } - func applyIcon(for provider: UsageProvider, phase: Double?) { - guard let button = self.statusItems[provider]?.button else { return } + private func shouldSkipProviderIconRender(provider: UsageProvider, signature: String) -> Bool { + if self.lastAppliedProviderIconRenderSignatures[provider] == signature { + return true + } + self.lastAppliedProviderIconRenderSignatures[provider] = signature + return false + } + + @discardableResult + func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { + guard let button = self.statusItems[provider]?.button else { return false } let snapshot = self.store.snapshot(for: provider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let style: IconStyle = self.store.style(for: provider) + let warningFlash = self.quotaWarningFlashActive(provider: provider) if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) - self.setButtonImage(brand, for: button) + let signature = [ + "mode=brandPercent", + "provider=\(provider.rawValue)", + "style=\(String(describing: style))", + "text=\(displayText ?? "nil")", + "warningFlash=\(warningFlash ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + return true + } + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) - return + return false } - if Self.shouldUseOpenRouterBrandFallback(provider: provider, snapshot: snapshot), - let brand = ProviderBrandIcon.image(for: provider) - { - self.setButtonTitle(nil, for: button) - self.setButtonImage( - Self.brandImageWithStatusOverlay( - brand: brand, - statusIndicator: self.store.statusIndicator(for: provider)), - for: button) - return - } + // OpenRouter always gets a meter here — the brand-logo fallback was removed on purpose. let resolved = snapshot.map { IconRemainingResolver.resolvedPercents( snapshot: $0, @@ -513,10 +505,41 @@ extension StatusItemController { }() let wiggle = self.wiggleAmount(for: provider) let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° + let statusIndicator = self.store.statusIndicator(for: provider) if let morphProgress { + let signature = [ + "mode=morph", + "provider=\(provider.rawValue)", + "style=\(String(describing: style))", + "morph=\(Self.iconSignatureValue(morphProgress))", + "status=\(statusIndicator.rawValue)", + "warningFlash=\(warningFlash ? "1" : "0")", + "loading=\(isLoading ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + return true + } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { + let signature = [ + "mode=icon", + "provider=\(provider.rawValue)", + "style=\(String(describing: style))", + "primary=\(Self.iconSignatureValue(primary))", + "weekly=\(Self.iconSignatureValue(weekly))", + "credits=\(Self.iconSignatureValue(credits))", + "stale=\(stale ? "1" : "0")", + "status=\(statusIndicator.rawValue)", + "blink=\(Self.iconSignatureValue(Double(blink)))", + "wiggle=\(Self.iconSignatureValue(Double(wiggle)))", + "tilt=\(Self.iconSignatureValue(Double(tilt)))", + "warningFlash=\(warningFlash ? "1" : "0")", + "loading=\(isLoading ? "1" : "0")", + ].joined(separator: "|") + if self.shouldSkipProviderIconRender(provider: provider, signature: signature) { + return true + } self.setButtonTitle(nil, for: button) let image = IconRenderer.makeIcon( primaryRemaining: primary, @@ -527,9 +550,38 @@ extension StatusItemController { blink: blink, wiggle: wiggle, tilt: tilt, - statusIndicator: self.store.statusIndicator(for: provider)) - self.setButtonImage(image, for: button) + statusIndicator: statusIndicator) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } + return false + } + + private static func iconSignatureValue(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.3f", value) + } + + func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { + guard let until = self.quotaWarningFlashUntil[provider] else { return false } + if until > now { return true } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks[provider]?.cancel() + self.quotaWarningFlashTasks.removeValue(forKey: provider) + return false + } + + static func quotaWarningFlashImage(base: NSImage) -> NSImage { + let image = NSImage(size: base.size) + image.lockFocus() + let rect = NSRect(origin: .zero, size: base.size) + NSColor.systemRed.withAlphaComponent(0.22).setFill() + NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4).fill() + base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + NSColor.systemRed.withAlphaComponent(0.28).setFill() + NSBezierPath(rect: rect).fill() + image.unlockFocus() + image.isTemplate = false + return image } private func setButtonImage(_ image: NSImage, for button: NSStatusBarButton) { @@ -538,7 +590,7 @@ extension StatusItemController { } private func setButtonTitle(_ title: String?, for button: NSStatusBarButton) { - let value = title ?? "" + let value = Self.buttonTitle(title, hasImage: button.image != nil) if button.title != value { button.title = value } @@ -548,7 +600,45 @@ extension StatusItemController { } } + nonisolated static func buttonTitle(_ title: String?, hasImage: Bool) -> String { + guard let title, !title.isEmpty else { return "" } + return hasImage ? " \(title)" : title + } + func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + if provider == .openrouter, + self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, + let balance = snapshot?.openRouterUsage?.balance + { + return UsageFormatter.usdString(balance) + } + if provider == .deepseek, + let balance = Self.deepSeekBalanceDisplayText(snapshot: snapshot) + { + return balance + } + if provider == .moonshot, + let balance = Self.moonshotBalanceDisplayText(snapshot: snapshot) + { + return balance + } + if provider == .mistral, + let spend = Self.mistralSpendDisplayText(snapshot: snapshot) + { + return spend + } + if provider == .kimik2, + let credits = Self.kimiK2CreditsDisplayText(snapshot: snapshot) + { + return credits + } + if provider == .kiro { + return Self.kiroDisplayText( + snapshot: snapshot, + mode: self.settings.kiroMenuBarDisplayMode, + showUsed: self.settings.usageBarsShowUsed) + } + let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) let mode = self.settings.menuBarDisplayMode let now = Date() @@ -590,6 +680,163 @@ extension StatusItemController { return displayText } + nonisolated static func deepSeekBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + guard let rawValue = snapshot?.primary?.resetDescription? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty, + rawValue.hasPrefix("$") || rawValue.hasPrefix("¥") + else { + return nil + } + + let balance = rawValue.split(separator: " ", maxSplits: 1).first + return balance.map(String.init) + } + + nonisolated static func moonshotBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + self.displayValue( + from: snapshot?.loginMethod(for: .moonshot), + prefix: "Balance:", + removingSuffix: "") + .flatMap { value in + value + .split(separator: "·", maxSplits: 1) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + nonisolated static func mistralSpendDisplayText(snapshot: UsageSnapshot?) -> String? { + self.displayValue( + from: snapshot?.identity?.loginMethod, + prefix: "API spend:", + removingSuffix: " this month") + } + + nonisolated static func kimiK2CreditsDisplayText(snapshot: UsageSnapshot?) -> String? { + self.displayValue( + from: snapshot?.identity?.loginMethod, + prefix: "Credits:", + removingSuffix: " left") + } + + nonisolated static func kiroDisplayText( + snapshot: UsageSnapshot?, + mode: KiroMenuBarDisplayMode, + showUsed: Bool) + -> String? + { + guard mode != .hidden else { return nil } + guard let usage = snapshot?.kiroUsage else { + return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: showUsed) + } + let percentText = MenuBarDisplayText.percentText( + window: snapshot?.primary, + showUsed: showUsed) + let creditsLeft = UsageFormatter.kiroCreditNumber(usage.creditsRemaining) + let usedTotal = [ + UsageFormatter.kiroCreditNumber(usage.creditsUsed), + UsageFormatter.kiroCreditNumber(usage.creditsTotal), + ].joined(separator: " / ") + + switch mode { + case .automatic, .creditsLeft: + if usage.creditsTotal > 0 { + return creditsLeft + } + return percentText + case .hidden: + return nil + case .percentLeft: + return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: false) + case .creditsAndPercent: + guard usage.creditsTotal > 0 else { return percentText } + guard let percentText else { return creditsLeft } + return "\(creditsLeft) · \(percentText)" + case .usedAndTotal: + guard usage.creditsTotal > 0 else { return percentText } + return usedTotal + case .overageCreditsWhenExhausted: + return self.kiroOverageDisplayText( + usage: usage, + format: .credits, + fallback: creditsLeft, + percentFallback: percentText) + case .overageCostWhenExhausted: + return self.kiroOverageDisplayText( + usage: usage, + format: .cost, + fallback: creditsLeft, + percentFallback: percentText) + case .overageCreditsAndCostWhenExhausted: + return self.kiroOverageDisplayText( + usage: usage, + format: .creditsAndCost, + fallback: creditsLeft, + percentFallback: percentText) + } + } + + private enum KiroOverageDisplayFormat { + case credits + case cost + case creditsAndCost + } + + private nonisolated static func kiroOverageDisplayText( + usage: KiroUsageDetails, + format: KiroOverageDisplayFormat, + fallback: String, + percentFallback: String?) + -> String? + { + guard usage.creditsTotal > 0 else { return percentFallback } + guard usage.creditsRemaining <= 0 else { return fallback } + guard usage.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("enabled") == true + else { + return fallback + } + + let credits = usage.overageCreditsUsed.map { "\(UsageFormatter.kiroCreditNumber($0)) over" } + let cost = usage.estimatedOverageCostUSD.map { "\(UsageFormatter.usdString($0)) over" } + + switch format { + case .credits: + return credits ?? cost ?? fallback + case .cost: + return cost ?? credits ?? fallback + case .creditsAndCost: + if let credits, let cost { + let creditsValue = credits.replacingOccurrences(of: " over", with: "") + let costValue = cost.replacingOccurrences(of: " over", with: "") + return "\(creditsValue) · \(costValue)" + } + return credits ?? cost ?? fallback + } + } + + private nonisolated static func displayValue( + from text: String?, + prefix: String, + removingSuffix suffix: String) + -> String? + { + guard let rawValue = text?.trimmingCharacters(in: .whitespacesAndNewlines), + rawValue.hasPrefix(prefix) + else { + return nil + } + let valueStart = rawValue.index(rawValue.startIndex, offsetBy: prefix.count) + var value = rawValue[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + if !suffix.isEmpty, value.hasSuffix(suffix) { + value = String(value.dropLast(suffix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value.isEmpty ? nil : value + } + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { self.menuBarMetricWindow(for: provider, snapshot: snapshot) } @@ -631,6 +878,9 @@ extension StatusItemController { } @objc func handleDebugBlinkNotification() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.forceBlinkNow() } @@ -691,46 +941,49 @@ extension StatusItemController { self.animationPattern = .knightRider } self.animationPhase = 0 + self.animationStartedAt = Date() let driver = DisplayLinkDriver(onTick: { [weak self] in self?.updateAnimationFrame() }) self.animationDriver = driver - driver.start(fps: 60) + driver.start(fps: Self.loadingAnimationFPS) } else if let forced = self.settings.debugLoadingPattern, forced != self.animationPattern { self.animationPattern = forced self.animationPhase = 0 } } else { - self.animationDriver?.stop() - self.animationDriver = nil - self.animationPhase = 0 - if self.shouldMergeIcons { - self.applyIcon(phase: nil) - } else { - UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: nil) } - } + self.stopLoadingAnimation() } } - private func updateAnimationFrame() { - self.animationPhase += 0.045 // half-speed animation + private func stopLoadingAnimation() { + self.animationDriver?.stop() + self.animationDriver = nil + self.animationPhase = 0 + self.animationStartedAt = nil if self.shouldMergeIcons { - self.applyIcon(phase: self.animationPhase) + self.applyIcon(phase: nil) } else { - UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: self.animationPhase) } + UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: nil) } } } - nonisolated static func shouldUseOpenRouterBrandFallback( - provider: UsageProvider, - snapshot: UsageSnapshot?) -> Bool - { - guard provider == .openrouter, - let openRouterUsage = snapshot?.openRouterUsage - else { - return false + private func updateAnimationFrame() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + if let startedAt = self.animationStartedAt, + Date().timeIntervalSince(startedAt) > Self.loadingAnimationMaxContinuousDuration + { + self.stopLoadingAnimation() + return + } + self.animationPhase += Self.loadingAnimationPhaseIncrement + if self.shouldMergeIcons { + self.applyIcon(phase: self.animationPhase) + } else { + UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: self.animationPhase) } } - return openRouterUsage.keyQuotaStatus == .noLimitConfigured } nonisolated static func brandImageWithStatusOverlay( @@ -784,6 +1037,9 @@ extension StatusItemController { } @objc func handleDebugReplayNotification(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif if let raw = notification.userInfo?["pattern"] as? String, let selected = LoadingPattern(rawValue: raw) { diff --git a/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift new file mode 100644 index 000000000..c26f9aa85 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CodexStackedMenu.swift @@ -0,0 +1,69 @@ +import AppKit + +extension StatusItemController { + func addStackedCodexMenuCards( + _ display: CodexAccountMenuDisplay, + to menu: NSMenu, + context: MenuCardContext) + { + let snapshotsByAccountID = Dictionary(uniqueKeysWithValues: display.snapshots.map { + ($0.account.id, $0) + }) + var cardIndex = 0 + let sections = display.showsWorkspaceGroups ? display.workspaceSections : [ + CodexAccountWorkspaceSection(title: "", accounts: display.accounts), + ] + + for (sectionIndex, section) in sections.enumerated() { + if display.showsWorkspaceGroups { + self.addCodexWorkspaceHeader(section.title, index: sectionIndex, to: menu) + } + + for account in section.accounts { + let accountSnapshot = snapshotsByAccountID[account.id] + let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error) + let model = self.menuCardModel( + for: .codex, + snapshotOverride: accountSnapshot?.snapshot, + errorOverride: health.label, + forceOverrideCard: accountSnapshot == nil, + accountOverride: self.accountInfo(for: account)) + guard let model else { continue } + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard-\(cardIndex)", + width: context.menuWidth)) + cardIndex += 1 + if account.id != section.accounts.last?.id { + menu.addItem(.separator()) + } + } + + if sectionIndex < sections.count - 1 { + menu.addItem(.separator()) + } + } + + if cardIndex == 0, let model = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard", + width: context.menuWidth)) + } + menu.addItem(.separator()) + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } + } + + private func addCodexWorkspaceHeader(_ title: String, index: Int, to menu: NSMenu) { + let header = NSMenuItem(title: title, action: nil, keyEquivalent: "") + header.isEnabled = false + header.representedObject = "codexWorkspace-\(index)" + let font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) + header.attributedTitle = NSAttributedString( + string: title, + attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]) + menu.addItem(header) + } +} diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift new file mode 100644 index 000000000..90bcaed76 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -0,0 +1,64 @@ +import AppKit + +extension StatusItemController { + static let costMenuTitle = "Cost" + + func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { + let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) + let visibleDetailLines = Self.costMenuVisibleDetailLines(tokenUsage: model.tokenUsage) + let item = NSMenuItem(title: Self.costMenuTitle, action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "menuCardCost" + item.submenu = submenu + item.toolTip = tooltipLines.joined(separator: "\n") + if #available(macOS 14.4, *) { + item.subtitle = visibleDetailLines.joined(separator: "\n") + } else if !visibleDetailLines.isEmpty { + item.attributedTitle = Self.costMenuFallbackAttributedTitle(visibleDetailLines: visibleDetailLines) + } + return item + } + + static func costMenuTooltipLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { + [ + tokenUsage?.sessionLine, + tokenUsage?.monthLine, + tokenUsage?.hintLine, + tokenUsage?.errorLine, + ] + .compactMap(\.self) + .filter { !$0.isEmpty } + } + + static func costMenuVisibleDetailLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { + let primaryLines = [ + tokenUsage?.sessionLine, + tokenUsage?.monthLine, + tokenUsage?.errorLine, + ] + .compactMap(\.self) + .filter { !$0.isEmpty } + guard primaryLines.isEmpty else { return primaryLines } + return [tokenUsage?.hintLine] + .compactMap(\.self) + .filter { !$0.isEmpty } + } + + static func costMenuFallbackAttributedTitle(visibleDetailLines: [String]) -> NSAttributedString { + let detailText = visibleDetailLines.joined(separator: " | ") + let title = detailText.isEmpty ? self.costMenuTitle : "\(self.costMenuTitle) \(detailText)" + let attributedTitle = NSMutableAttributedString( + string: title, + attributes: [.font: NSFont.menuFont(ofSize: NSFont.systemFontSize)]) + guard !detailText.isEmpty else { return attributedTitle } + + let detailRange = (title as NSString).range(of: detailText) + attributedTitle.addAttributes( + [ + .font: NSFont.menuFont(ofSize: NSFont.smallSystemFontSize), + .foregroundColor: NSColor.secondaryLabelColor, + ], + range: detailRange) + return attributedTitle + } +} diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index c716636b2..4d2dbb524 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -3,18 +3,42 @@ import CodexBarCore import SwiftUI extension StatusItemController { - func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu { + func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { + let ids: Set = [ + Self.usageBreakdownChartID, + Self.creditsHistoryChartID, + Self.costHistoryChartID, + Self.openAIAPIUsageChartID, + Self.usageHistoryChartID, + Self.storageBreakdownID, + Self.zaiHourlyUsageChartID, + ] + return menu.items.contains { item in + guard let id = item.representedObject as? String else { return false } + return ids.contains(id) + } + } + + func makeHostedSubviewPlaceholderMenu( + chartID: String, + provider: UsageProvider? = nil, + width: CGFloat? = nil) -> NSMenu + { let submenu = NSMenu() + submenu.autoenablesItems = false + if let width { + submenu.minimumWidth = width + } submenu.delegate = self let chartItem = NSMenuItem() - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = chartID chartItem.toolTip = provider?.rawValue submenu.addItem(chartItem) return submenu } - func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu) { + func hydrateHostedSubviewMenuIfNeeded(_ menu: NSMenu, width requestedWidth: CGFloat? = nil) { guard let placeholder = menu.items.first, menu.items.count == 1, placeholder.view == nil, @@ -23,7 +47,7 @@ extension StatusItemController { return } - let width = self.renderedMenuWidth(for: menu.supermenu ?? menu) + let width = requestedWidth ?? self.renderedMenuWidth(for: menu.supermenu ?? menu) menu.removeAllItems() let didHydrate: Bool = switch chartID { @@ -39,6 +63,14 @@ 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) @@ -47,6 +79,22 @@ extension StatusItemController { } else { false } + case Self.storageBreakdownID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.zaiHourlyUsageChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendZaiHourlyUsageChartItem(to: menu, provider: provider, width: width) + } else { + false + } default: false } @@ -62,12 +110,13 @@ extension StatusItemController { @discardableResult func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) guard !breakdown.isEmpty else { return false } if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.usageBreakdownChartID submenu.addItem(chartItem) return true @@ -81,7 +130,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.usageBreakdownChartID submenu.addItem(chartItem) return true @@ -94,7 +143,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.creditsHistoryChartID submenu.addItem(chartItem) return true @@ -108,7 +157,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.creditsHistoryChartID submenu.addItem(chartItem) return true @@ -125,7 +174,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID submenu.addItem(chartItem) return true @@ -143,9 +192,119 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting - chartItem.isEnabled = false + 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 + submenu.addItem(chartItem) + return true + } + + @discardableResult + func appendStorageBreakdownItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) + -> Bool + { + guard let footprint = self.store.storageFootprint(for: provider), + !footprint.components.isEmpty + else { return false } + + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem() + item.isEnabled = true + item.representedObject = Self.storageBreakdownID + item.toolTip = provider.rawValue + submenu.addItem(item) + return true + } + + let maxHeight = self.storageBreakdownMenuMaxHeight() + let view = StorageBreakdownMenuView(footprint: footprint, width: width, maxHeight: maxHeight) + let hosting = MenuHostingView(rootView: view) + let controller = NSHostingController(rootView: view) + let size = controller.sizeThatFits(in: CGSize(width: width, height: maxHeight)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let item = NSMenuItem() + item.view = hosting + item.isEnabled = true + item.representedObject = Self.storageBreakdownID + item.toolTip = provider.rawValue + submenu.addItem(item) + return true + } + + private func storageBreakdownMenuMaxHeight() -> CGFloat { + let visibleHeight = NSScreen.main?.visibleFrame.height ?? 900 + return min(620, max(360, floor(visibleHeight * 0.72))) + } + + @discardableResult + func appendZaiHourlyUsageChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard provider == .zai, + let snapshot = self.store.snapshot(for: provider), + let modelUsage = snapshot.zaiUsage?.modelUsage + else { return false } + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = Self.zaiHourlyUsageChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } + + let chartView = ZaiHourlyUsageChartMenuView(modelUsage: modelUsage, 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 = false + chartItem.representedObject = Self.zaiHourlyUsageChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 48a295e5e..bc8b0b7b9 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -7,14 +7,35 @@ import SwiftUI // MARK: - NSMenu construction extension StatusItemController { - private static let menuCardBaseWidth: CGFloat = 310 + static let menuCardBaseWidth: CGFloat = 310 private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit private static let overviewRowIdentifierPrefix = "overviewRow-" - private static let menuOpenRefreshDelay: Duration = .seconds(1.2) + private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) + #if DEBUG + private static var menuOpenRefreshDelayForTesting: Duration = .seconds(1.2) + static func setMenuOpenRefreshDelayForTesting(_ delay: Duration) { + self.menuOpenRefreshDelayForTesting = delay + } + + static func resetMenuOpenRefreshDelayForTesting() { + self.menuOpenRefreshDelayForTesting = self.defaultMenuOpenRefreshDelay + } + #endif + + private static var menuOpenRefreshDelay: Duration { + #if DEBUG + menuOpenRefreshDelayForTesting + #else + defaultMenuOpenRefreshDelay + #endif + } + static let usageBreakdownChartID = "usageBreakdownChart" static let creditsHistoryChartID = "creditsHistoryChart" static let costHistoryChartID = "costHistoryChart" + static let openAIAPIUsageChartID = "openAIAPIUsageChart" static let usageHistoryChartID = "usageHistoryChart" + static let storageBreakdownID = "storageBreakdown" private func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { switch action { @@ -45,19 +66,11 @@ extension StatusItemController { return ceil(measuringMenu.size.width) } - func renderedMenuWidth(for menu: NSMenu) -> CGFloat { - let measuredWidth = ceil(menu.size.width) - return max(measuredWidth, Self.menuCardBaseWidth) - } - func makeMenu() -> NSMenu { guard self.shouldMergeIcons else { return self.makeMenu(for: nil) } - let menu = NSMenu() - menu.autoenablesItems = false - menu.delegate = self - return menu + return self.makeBaseMenu() } func menuWillOpen(_ menu: NSMenu) { @@ -67,7 +80,11 @@ extension StatusItemController { if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + // 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 + } // Removed redundant async refresh - single pass is sufficient after initial layout return } @@ -97,7 +114,11 @@ extension StatusItemController { self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - self.openMenus[ObjectIdentifier(menu)] = menu + if Self.menuRefreshEnabled { + // 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.scheduleOpenMenuRefresh(for: menu) @@ -105,6 +126,14 @@ extension StatusItemController { } func menuDidClose(_ menu: NSMenu) { + let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) + self.forgetClosedMenu(menu) + if wasHostedSubviewMenu { + self.refreshOpenMenusIfNeeded() + } + } + + func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.openMenus.removeValue(forKey: key) @@ -129,7 +158,7 @@ extension StatusItemController { } } - private func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -144,12 +173,18 @@ extension StatusItemController { switcherSelection?.provider ?? provider } let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex - let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) + let rawCodexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) + let codexAccountDisplay = isOverviewSelected + ? nil + : self.stableCodexAccountMenuDisplay( + rawCodexAccountDisplay, + menu: menu, + provider: currentProvider) let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) - let showAllTokenAccounts = tokenAccountDisplay?.showAll ?? false + let showAllAccounts = (tokenAccountDisplay?.showAll ?? false) || (codexAccountDisplay?.showAll ?? false) let openAIContext = self.openAIWebContext( currentProvider: currentProvider, - showAllTokenAccounts: showAllTokenAccounts) + showAllAccounts: showAllAccounts) let descriptor = MenuDescriptor.build( provider: selectedProvider, store: self.store, @@ -167,13 +202,19 @@ extension StatusItemController { let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview - let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher + let tokenSwitcherCompatible = tokenAccountDisplay == self.lastTokenAccountMenuDisplay && + ((tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || + (tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && - ((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher)) + ((codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || + (codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in guard let view = item.view else { return false } return abs(view.frame.width - menuWidth) <= 0.5 } + let providerSwitcherWidthMatches = (menu.items.first?.view as? ProviderSwitcherView).map { view in + abs(view.frame.width - menuWidth) <= 0.5 + } ?? false let canSmartUpdate = self.shouldMergeIcons && enabledProviders.count > 1 && !isOverviewSelected && @@ -187,62 +228,81 @@ extension StatusItemController { !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): provider=\(String(describing: provider)) " + + "display=\(enabledProviders.map(\.rawValue)) " + + "available=\(self.store.enabledProviders().map(\.rawValue)) " + + "selection=\(String(describing: switcherSelection)) " + + "last=\(String(describing: self.lastMergedSwitcherSelection)) " + + "smart=\(canSmartUpdate)") + } + #endif + if canSmartUpdate { - self.updateMenuContent( + self.updateMenuContentPreservingSwitcher( menu, - provider: selectedProvider, - currentProvider: currentProvider, - menuWidth: menuWidth, - openAIContext: openAIContext) + context: MenuUpdateContext( + provider: selectedProvider, + currentProvider: currentProvider, + switcherSelection: switcherSelection ?? .provider(currentProvider), + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext)) return } - menu.removeAllItems() - self.addProviderSwitcherIfNeeded( - to: menu, - enabledProviders: enabledProviders, - includesOverview: includesOverview, - selection: switcherSelection ?? .provider(currentProvider), - width: menuWidth) - // Track which providers the switcher was built with for smart update detection - if self.shouldMergeIcons, enabledProviders.count > 1 { - self.lastSwitcherProviders = enabledProviders - self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed - self.lastMergedSwitcherSelection = switcherSelection - self.lastSwitcherIncludesOverview = includesOverview + let canPreserveProviderSwitcher = self.shouldMergeIcons && + enabledProviders.count > 1 && + switcherProvidersMatch && + switcherUsageBarsShowUsedMatch && + switcherOverviewAvailabilityMatches && + providerSwitcherWidthMatches && + !menu.items.isEmpty && + menu.items.first?.view is ProviderSwitcherView + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): preserveSwitcher=\(canPreserveProviderSwitcher) " + + "widthMatch=\(providerSwitcherWidthMatches)") } - self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth) - self.lastCodexAccountMenuDisplay = codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth) - let menuContext = MenuCardContext( - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext) - if isOverviewSelected { - if self.addOverviewRows( - to: menu, + #endif + + if canPreserveProviderSwitcher { + self.updateMenuContentPreservingSwitcher( + menu, + context: MenuUpdateContext( + provider: selectedProvider, + currentProvider: currentProvider, + switcherSelection: switcherSelection ?? .provider(currentProvider), + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext)) + return + } + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView { + self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") + } + #endif + self.rebuildMenuContent( + menu, + context: MenuRebuildContext( enabledProviders: enabledProviders, - menuWidth: menuWidth) - { - menu.addItem(.separator()) - } else { - self.addOverviewEmptyState(to: menu, enabledProviders: enabledProviders) - menu.addItem(.separator()) - } - } else { - let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) - self.addOpenAIWebItemsIfNeeded( - to: menu, + includesOverview: includesOverview, + switcherSelection: switcherSelection, currentProvider: currentProvider, - context: openAIContext, - addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { - menu.addItem(.separator()) - } - } - self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) + selectedProvider: selectedProvider, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext, + descriptor: descriptor)) } private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { @@ -268,83 +328,114 @@ extension StatusItemController { return reusableRows } - /// Smart update: only rebuild content sections when switching providers (keep the switcher intact). - private func updateMenuContent( - _ menu: NSMenu, - provider: UsageProvider?, - currentProvider: UsageProvider, - menuWidth: CGFloat, - openAIContext: OpenAIWebContext) - { - // Batch menu updates to prevent visual flickering during provider switch. - CATransaction.begin() - CATransaction.setDisableActions(true) - defer { CATransaction.commit() } - - var contentStartIndex = 0 - if menu.items.first?.view is ProviderSwitcherView { - contentStartIndex = 2 - } - if menu.items.count > contentStartIndex, - menu.items[contentStartIndex].view is CodexAccountSwitcherView - { - contentStartIndex += 2 - } - if menu.items.count > contentStartIndex, - menu.items[contentStartIndex].view is TokenAccountSwitcherView - { - contentStartIndex += 2 - } - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) - } - - let descriptor = MenuDescriptor.build( - provider: provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady) - - let menuContext = MenuCardContext( - currentProvider: currentProvider, - selectedProvider: provider, - menuWidth: menuWidth, - tokenAccountDisplay: nil, - openAIContext: openAIContext) - let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) - self.addOpenAIWebItemsIfNeeded( - to: menu, - currentProvider: currentProvider, - context: openAIContext, - addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) { - menu.addItem(.separator()) - } - self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) - } - - private struct OpenAIWebContext { - let hasUsageBreakdown: Bool - let hasCreditsHistory: Bool - let hasCostHistory: Bool - let canShowBuyCredits: Bool - let hasOpenAIWebMenuItems: Bool - } - - private struct MenuCardContext { + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + private struct MenuUpdateContext { + let provider: UsageProvider? let currentProvider: UsageProvider - let selectedProvider: UsageProvider? + let switcherSelection: ProviderSwitcherSelection let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? let tokenAccountDisplay: TokenAccountMenuDisplay? let openAIContext: OpenAIWebContext } + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. + private func updateMenuContentPreservingSwitcher( + _ menu: NSMenu, + context: MenuUpdateContext) + { + self.performMenuMutationWithoutAnimation { + let contentStartIndex = menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + (menu.items.first?.view as? ProviderSwitcherView)?.updateSelection(context.switcherSelection) + while menu.items.count > contentStartIndex { + 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.addCodexAccountSwitcherIfNeeded( + to: menu, + display: context.codexAccountDisplay, + width: context.menuWidth) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: menu, + display: context.tokenAccountDisplay, + width: context.menuWidth) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let descriptor = MenuDescriptor.build( + provider: context.provider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: context.switcherSelection != .overview) + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) + self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) + } + } + + private func rebuildMenuContent( + _ menu: NSMenu, + context: MenuRebuildContext) + { + self.performMenuMutationWithoutAnimation { + menu.removeAllItems() + self.addProviderSwitcherIfNeeded( + to: menu, + enabledProviders: context.enabledProviders, + includesOverview: context.includesOverview, + selection: context.switcherSelection ?? .provider(context.currentProvider), + 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.addCodexAccountSwitcherIfNeeded( + to: menu, + display: context.codexAccountDisplay, + width: context.menuWidth) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: menu, + display: context.tokenAccountDisplay, + width: context.menuWidth) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.selectedProvider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: menu, + context: menuContext, + switcherSelection: context.switcherSelection ?? .provider(context.currentProvider)) + self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + } + } + private func openAIWebContext( currentProvider: UsageProvider, - showAllTokenAccounts: Bool) -> OpenAIWebContext + showAllAccounts: Bool) -> OpenAIWebContext { let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: currentProvider, @@ -355,7 +446,7 @@ extension StatusItemController { (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true - let hasOpenAIWebMenuItems = !showAllTokenAccounts && + let hasOpenAIWebMenuItems = !showAllAccounts && (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) return OpenAIWebContext( hasUsageBreakdown: hasUsageBreakdown, @@ -391,7 +482,7 @@ extension StatusItemController { } private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) { - guard let display else { return } + guard let display, display.showSwitcher else { return } let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width) menu.addItem(switcherItem) menu.addItem(.separator()) @@ -408,16 +499,23 @@ extension StatusItemController { let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders .compactMap { provider in guard let model = self.menuCardModel(for: provider) else { return nil } + guard !model.isOverviewErrorOnly else { return nil } return (provider: provider, model: model) } guard !rows.isEmpty else { return false } for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" + let storageText = self.store.storageFootprintText(for: row.provider) + let submenu = self.makeOverviewRowSubmenu( + provider: row.provider, + model: row.model, + width: menuWidth) let item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, width: menuWidth), + OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), id: identifier, width: menuWidth, + submenu: submenu, onClick: { [weak self, weak menu] in guard let self, let menu else { return } self.selectOverviewProvider(row.provider, menu: menu) @@ -437,11 +535,7 @@ extension StatusItemController { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders) - let message = if resolvedProviders.isEmpty { - "No providers selected for Overview." - } else { - "No overview data available." - } + let message = resolvedProviders.isEmpty ? "No providers selected for Overview." : "No overview data available." let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") item.isEnabled = false item.representedObject = "overviewEmptyState" @@ -449,6 +543,11 @@ extension StatusItemController { } private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool { + if let codexAccountDisplay = context.codexAccountDisplay, codexAccountDisplay.showAll { + self.addStackedCodexMenuCards(codexAccountDisplay, to: menu, context: context) + return false + } + if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty @@ -459,31 +558,26 @@ extension StatusItemController { snapshotOverride: accountSnapshot.snapshot, errorOverride: accountSnapshot.error) } - if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), - id: "menuCard", - width: context.menuWidth)) - menu.addItem(.separator()) - } else { - for (index, model) in cards.enumerated() { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, width: context.menuWidth), - id: "menuCard-\(index)", - width: context.menuWidth)) - if index < cards.count - 1 { - menu.addItem(.separator()) - } - } - if !cards.isEmpty { - menu.addItem(.separator()) - } + self.addStackedMenuCards(cards, to: menu, context: context) + return false + } + + if context.currentProvider == .kilo, self.store.kiloScopeSnapshots.count > 1 { + let cards = self.store.kiloScopeSnapshots.compactMap { scope in + self.menuCardModel( + for: .kilo, + snapshotOverride: scope.snapshot, + errorOverride: scope.errorMessage, + forceOverrideCard: scope.snapshot == nil) } + self.addStackedMenuCards(cards, to: menu, context: context) return false } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } - if context.openAIContext.hasOpenAIWebMenuItems { + if context.openAIContext.hasOpenAIWebMenuItems || self + .hasOpenAIAPIUsageSubmenu(provider: context.currentProvider) + { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, hasCreditsHistory: context.openAIContext.hasCreditsHistory, @@ -502,6 +596,9 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth)) + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } if context.openAIContext.canShowBuyCredits { menu.addItem(self.makeBuyCreditsItem()) } @@ -509,6 +606,36 @@ extension StatusItemController { return false } + private func addStackedMenuCards( + _ cards: [UsageMenuCardView.Model], + to menu: NSMenu, + context: MenuCardContext) + { + if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard", + width: context.menuWidth)) + menu.addItem(.separator()) + } else { + for (index, model) in cards.enumerated() { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, width: context.menuWidth), + id: "menuCard-\(index)", + width: context.menuWidth)) + if index < cards.count - 1 { + menu.addItem(.separator()) + } + } + if !cards.isEmpty { + menu.addItem(.separator()) + } + } + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } + } + private func addOpenAIWebItemsIfNeeded( to menu: NSMenu, currentProvider: UsageProvider, @@ -531,6 +658,48 @@ extension StatusItemController { menu.addItem(.separator()) } + private func addPrimaryMenuContent( + to menu: NSMenu, + context: MenuCardContext, + switcherSelection: ProviderSwitcherSelection) + { + self.store.refreshStorageFootprintsForOverview() + if switcherSelection == .overview { + let enabledProviders = self.store.enabledProvidersForDisplay() + if self.addOverviewRows( + to: menu, + enabledProviders: enabledProviders, + menuWidth: context.menuWidth) + { + menu.addItem(.separator()) + } else { + self.addOverviewEmptyState(to: menu, enabledProviders: enabledProviders) + menu.addItem(.separator()) + } + } else { + let addedOpenAIWebItems = self.addMenuCards(to: menu, context: context) + self.addOpenAIWebItemsIfNeeded( + to: menu, + currentProvider: context.currentProvider, + context: context.openAIContext, + addedOpenAIWebItems: addedOpenAIWebItems) + if self.addUsageHistoryMenuItemIfNeeded( + to: menu, + provider: context.currentProvider, + width: context.menuWidth) + { + menu.addItem(.separator()) + } + if self.addZaiHourlyUsageMenuItemIfNeeded( + to: menu, + provider: context.currentProvider, + width: context.menuWidth) + { + menu.addItem(.separator()) + } + } + } + private func addActionableSections(_ sections: [MenuDescriptor.Section], to menu: NSMenu, width: CGFloat) { let actionableSections = sections.filter { section in section.entries.contains { entry in @@ -560,6 +729,11 @@ extension StatusItemController { } menu.addItem(item) case let .action(title, action): + if case .refresh = action { + menu.addItem(self.makePersistentMenuActionItem(title: title, action: action, width: width)) + continue + } + let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") item.target = self @@ -622,6 +796,56 @@ extension StatusItemController { } } + private func makePersistentMenuActionItem( + title: String, + action: MenuDescriptor.MenuAction, + width: CGFloat) -> NSMenuItem + { + let shortcut = self.shortcut(for: action) + let row = PersistentMenuActionItemView( + title: title, + systemImageName: action.systemImageName, + shortcutText: shortcut.map { self.shortcutLabel(for: $0) }, + width: width, + onClick: { [weak self] in + self?.performPersistentMenuAction(action) + }) + + let item = NSMenuItem(title: title, action: nil, keyEquivalent: shortcut?.key ?? "") + item.keyEquivalentModifierMask = shortcut?.modifiers ?? NSEvent.ModifierFlags() + item.isEnabled = true + item.view = row + item.toolTip = title + return item + } + + private func performPersistentMenuAction(_ action: MenuDescriptor.MenuAction) { + switch action { + case .refresh: + self.refreshNow() + default: + break + } + } + + private func shortcutLabel(for shortcut: (key: String, modifiers: NSEvent.ModifierFlags)) -> String { + var label = "" + if shortcut.modifiers.contains(.control) { + label += "^" + } + if shortcut.modifiers.contains(.option) { + label += "⌥" + } + if shortcut.modifiers.contains(.shift) { + label += "⇧" + } + if shortcut.modifiers.contains(.command) { + label += "⌘" + } + label += shortcut.key.uppercased() + return label + } + private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") let view = self.makeWrappedSecondaryTextView(text: text) @@ -663,15 +887,21 @@ extension StatusItemController { } func makeMenu(for provider: UsageProvider?) -> NSMenu { - let menu = NSMenu() - menu.autoenablesItems = false - menu.delegate = self + let menu = self.makeBaseMenu() if let provider { self.menuProviders[ObjectIdentifier(menu)] = provider } return menu } + private func makeBaseMenu() -> NSMenu { + let menu = StatusItemMenu() + menu.autoenablesItems = false + menu.delegate = self + menu.persistentActionDelegate = self + return menu + } + private func makeProviderSwitcherItem( providers: [UsageProvider], includesOverview: Bool, @@ -693,22 +923,24 @@ extension StatusItemController { }, onSelect: { [weak self, weak menu] selection in guard let self, let menu else { return } + let provider: UsageProvider? switch selection { case .overview: self.settings.mergedMenuLastSelectedWasOverview = true - self.lastMergedSwitcherSelection = .overview - let provider = self.resolvedMenuProvider() + provider = self.resolvedMenuProvider() + case let .provider(selectedProvider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = selectedProvider + provider = selectedProvider + } + switch selection { + case .overview: self.lastMenuProvider = provider ?? .codex - self.populateMenu(menu, provider: provider) case let .provider(provider): - self.settings.mergedMenuLastSelectedWasOverview = false - self.lastMergedSwitcherSelection = .provider(provider) - self.selectedMenuProvider = provider self.lastMenuProvider = provider - self.populateMenu(menu, provider: provider) } - self.markMenuFresh(menu) - self.applyIcon(phase: nil) + self.lastMergedSwitcherSelection = selection + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) }) let item = NSMenuItem() item.view = view @@ -725,17 +957,19 @@ extension StatusItemController { accounts: display.accounts, selectedIndex: display.activeIndex, width: width, - onSelect: { [weak self, weak menu] index in - guard let self, let menu else { return } + onSelect: { [weak self, weak menu] index -> Task? in + guard let self, let menu else { return nil } self.settings.setActiveTokenAccountIndex(index, for: display.provider) - Task { @MainActor in + self.applyIcon(phase: nil) + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: display.provider) + return Task { @MainActor [weak self, weak menu] in + guard let self else { return } await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh() + await self.store.refreshProvider(display.provider) } + guard let menu else { return } + self.refreshOpenMenuIfStillVisible(menu, provider: display.provider) } - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view @@ -752,9 +986,9 @@ extension StatusItemController { accounts: display.accounts, selectedAccountID: display.activeVisibleAccountID, width: width, - onSelect: { [weak self, weak menu] visibleAccountID in + onSelect: { [weak self, weak menu] account in guard let self else { return } - self.handleCodexVisibleAccountSelection(visibleAccountID, menu: menu) + self.handleCodexVisibleAccountSelection(account, menu: menu) }) let item = NSMenuItem() item.view = view @@ -763,10 +997,11 @@ extension StatusItemController { } @discardableResult - private func handleCodexVisibleAccountSelection(_ visibleAccountID: String, menu: NSMenu?) -> Bool { - guard self.settings.selectCodexVisibleAccount(id: visibleAccountID) else { return false } + private func handleCodexVisibleAccountSelection(_ account: CodexVisibleAccount, menu: NSMenu?) -> Bool { + let visibleAccountID = account.id + self.settings.selectDisplayedCodexVisibleAccount(account) if self.store.prepareCodexAccountScopedRefreshIfNeeded(), let menu { - self.refreshOpenMenuIfStillVisible(menu, provider: .codex) + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) } Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { @@ -812,68 +1047,17 @@ extension StatusItemController { return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) } - private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { - guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } - let accounts = self.settings.tokenAccounts(for: provider) - guard accounts.count > 1 else { return nil } - let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 - let showAll = self.settings.showAllTokenAccountsInMenu - let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] - return TokenAccountMenuDisplay( - provider: provider, - accounts: accounts, - snapshots: snapshots, - activeIndex: activeIndex, - showAll: showAll, - showSwitcher: !showAll) - } - - private func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { - guard provider == .codex else { return nil } - let projection = self.settings.codexVisibleAccountProjection - guard projection.visibleAccounts.count > 1 else { return nil } - return CodexAccountMenuDisplay( - accounts: projection.visibleAccounts, - activeVisibleAccountID: projection.activeVisibleAccountID) - } - - private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { let key = ObjectIdentifier(menu) return self.menuVersions[key] != self.menuContentVersion } - private func markMenuFresh(_ menu: NSMenu) { + func markMenuFresh(_ menu: NSMenu) { let key = ObjectIdentifier(menu) self.menuVersions[key] = self.menuContentVersion } - func refreshOpenMenusIfNeeded() { - guard !self.openMenus.isEmpty else { return } - for (key, menu) in self.openMenus { - guard key == ObjectIdentifier(menu) else { - // Clean up orphaned menu entries from all tracking dictionaries - self.openMenus.removeValue(forKey: key) - self.menuRefreshTasks.removeValue(forKey: key)?.cancel() - self.menuProviders.removeValue(forKey: key) - self.menuVersions.removeValue(forKey: key) - continue - } - - if self.isHostedSubviewMenu(menu) { - self.refreshHostedSubviewHeights(in: menu) - continue - } - - if self.menuNeedsRefresh(menu) { - let provider = self.menuProvider(for: menu) - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - // Heights are already set during populateMenu, no need to remeasure - } - } - } - - private func menuProvider(for menu: NSMenu) -> UsageProvider? { + func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() } @@ -886,6 +1070,10 @@ 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 @@ -903,8 +1091,9 @@ extension StatusItemController { } } - private func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + 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) @@ -914,10 +1103,10 @@ extension StatusItemController { } private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a user-initiated refresh on open (non-forced) and re-check after a delay. + // 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) + self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -925,6 +1114,10 @@ extension StatusItemController { guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } + guard Self.menuRefreshEnabled else { return } + #if DEBUG + self.onDelayedMenuRefreshAttemptForTesting?() + #endif guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard !self.store.isRefreshing else { return } let retryProviders = self.delayedRefreshRetryProviders(for: menu) @@ -932,7 +1125,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) + self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) } } @@ -1055,39 +1248,46 @@ extension StatusItemController { width: CGFloat, webItems: OpenAIWebMenuItems) { - let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil + let hasUsageBlock = model.hasUsageContent let hasCredits = model.creditsText != nil let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil + let hasStorage = self.store.storageFootprintText(for: provider) != nil let bottomPadding = CGFloat(hasCredits ? 4 : 6) let sectionSpacing = CGFloat(6) let usageBottomPadding = bottomPadding let creditsBottomPadding = bottomPadding - let headerView = UsageMenuCardHeaderSectionView( - model: model, - showDivider: hasUsageBlock, - width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) - if hasUsageBlock { - let usageView = UsageMenuCardUsageSectionView( + let usageView = UsageMenuCardHeaderAndUsageSectionView( model: model, - showBottomDivider: false, bottomPadding: usageBottomPadding, width: width) let usageSubmenu = self.makeUsageSubmenu( provider: provider, snapshot: self.store.snapshot(for: provider), - webItems: webItems) + webItems: webItems, + width: width) menu.addItem(self.makeMenuCardItem( usageView, id: "menuCardUsage", width: width, submenu: usageSubmenu)) + } else { + let headerView = UsageMenuCardHeaderSectionView( + model: model, + showDivider: false, + width: width) + menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) } - if hasCredits || hasExtraUsage || hasCost { + if hasStorage || hasCredits || hasExtraUsage || hasCost { + menu.addItem(.separator()) + } + + if self.addStorageMenuCardSection(to: menu, provider: provider, width: width), + hasCredits || hasExtraUsage || hasCost + { menu.addItem(.separator()) } @@ -1101,7 +1301,7 @@ extension StatusItemController { topPadding: sectionSpacing, bottomPadding: creditsBottomPadding, width: width) - let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu() : nil + let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu(width: width) : nil menu.addItem(self.makeMenuCardItem( creditsView, id: "menuCardCredits", @@ -1115,6 +1315,7 @@ extension StatusItemController { if hasCredits { menu.addItem(.separator()) } + let extraUsageSubmenu = self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) let extraUsageView = UsageMenuCardExtraUsageSectionView( model: model, topPadding: sectionSpacing, @@ -1123,26 +1324,36 @@ extension StatusItemController { menu.addItem(self.makeMenuCardItem( extraUsageView, id: "menuCardExtraUsage", - width: width)) + width: width, + submenu: extraUsageSubmenu)) } if hasCost { if hasCredits || hasExtraUsage { menu.addItem(.separator()) } - let costView = UsageMenuCardCostSectionView( - model: model, - topPadding: sectionSpacing, - bottomPadding: bottomPadding, - width: width) - let costSubmenu = webItems.hasCostHistory ? self.makeCostHistorySubmenu(provider: provider) : nil - menu.addItem(self.makeMenuCardItem( - costView, - id: "menuCardCost", - width: width, - submenu: costSubmenu)) + let costSubmenu = webItems.hasCostHistory ? self + .makeCostHistorySubmenu(provider: provider, width: width) : nil + menu.addItem(self.makeCostMenuCardItem(model: model, submenu: costSubmenu)) } } + @discardableResult + func addStorageMenuCardSection(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard let storageText = self.store.storageFootprintText(for: provider) else { return false } + let storageView = StorageMenuCardSectionView( + storageText: storageText, + topPadding: 6, + bottomPadding: 6, + width: width) + let storageSubmenu = self.makeStorageBreakdownSubmenu(provider: provider, width: width) + menu.addItem(self.makeMenuCardItem( + storageView, + id: "menuCardStorage", + width: width, + submenu: storageSubmenu)) + return true + } + private func switcherIcon(for provider: UsageProvider) -> NSImage { if let brand = ProviderBrandIcon.image(for: provider) { return brand @@ -1216,7 +1427,8 @@ extension StatusItemController { @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { - guard let submenu = self.makeCreditsHistorySubmenu() else { return false } + guard let submenu = self.makeCreditsHistorySubmenu(width: self.renderedMenuWidth(for: menu)) + else { return false } let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu @@ -1226,7 +1438,8 @@ extension StatusItemController { @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { - guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } + guard let submenu = self.makeUsageBreakdownSubmenu(width: self.renderedMenuWidth(for: menu)) + else { return false } let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu @@ -1236,7 +1449,8 @@ extension StatusItemController { @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { - guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } + guard let submenu = self.makeCostHistorySubmenu(provider: provider, width: self.renderedMenuWidth(for: menu)) + else { return false } let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu @@ -1247,10 +1461,14 @@ extension StatusItemController { private func makeUsageSubmenu( provider: UsageProvider, snapshot: UsageSnapshot?, - webItems: OpenAIWebMenuItems) -> NSMenu? + webItems: OpenAIWebMenuItems, + width: CGFloat? = nil) -> NSMenu? { if webItems.hasUsageBreakdown { - return self.makeUsageBreakdownSubmenu() + return self.makeUsageBreakdownSubmenu(width: width) + } + if provider == .openai { + return self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) } if provider == .zai { return self.makeZaiUsageDetailsSubmenu(snapshot: snapshot) @@ -1258,7 +1476,7 @@ extension StatusItemController { return nil } - private func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { + func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } guard !timeLimit.usageDetails.isEmpty else { return nil } @@ -1269,7 +1487,7 @@ extension StatusItemController { submenu.addItem(titleItem) if let window = timeLimit.windowLabel { - let item = NSMenuItem(title: "Window: \(window)", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: String(format: L("mcp_window"), window), action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } @@ -1277,7 +1495,7 @@ extension StatusItemController { let reset = self.settings.resetTimeDisplayStyle == .absolute ? UsageFormatter.resetDescription(from: resetTime) : UsageFormatter.resetCountdownDescription(from: resetTime) - let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: String(format: L("mcp_resets"), reset), action: nil, keyEquivalent: "") item.isEnabled = false submenu.addItem(item) } @@ -1288,39 +1506,69 @@ extension StatusItemController { } for detail in sortedDetails { let usage = UsageFormatter.tokenCountString(detail.usage) - let item = NSMenuItem(title: "\(detail.modelCode): \(usage)", action: nil, keyEquivalent: "") + let item = NSMenuItem( + title: String(format: L("mcp_model_usage"), detail.modelCode, usage), + action: nil, + keyEquivalent: "") submenu.addItem(item) } return submenu } - private func makeUsageBreakdownSubmenu() -> NSMenu? { - guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + private func makeUsageBreakdownSubmenu(width: CGFloat? = nil) -> NSMenu? { + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) + guard !breakdown.isEmpty else { return nil } + if let width { + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID, width: width) + } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } - private func makeCreditsHistorySubmenu() -> NSMenu? { + private func makeCreditsHistorySubmenu(width: CGFloat? = nil) -> NSMenu? { guard !(self.store.openAIDashboard?.dailyBreakdown ?? []).isEmpty else { return nil } + if let width { + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.creditsHistoryChartID, width: width) + } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.creditsHistoryChartID) } - private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + 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 } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.costHistoryChartID, + provider: provider, + width: width) + } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } - private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool { - let ids: Set = [ - Self.usageBreakdownChartID, - Self.creditsHistoryChartID, - Self.costHistoryChartID, - Self.usageHistoryChartID, - ] - return menu.items.contains { item in - guard let id = item.representedObject as? String else { return false } - return ids.contains(id) + 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) + } + + private func hasOpenAIAPIUsageSubmenu(provider: UsageProvider) -> Bool { + provider == .openai && self.store.snapshot(for: provider)?.openAIAPIUsage?.daily.isEmpty == false + } + + func makeStorageBreakdownSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { + guard self.store.storageFootprint(for: provider)?.components.isEmpty == false else { return nil } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.storageBreakdownID, + provider: provider, + width: width) } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.storageBreakdownID, provider: provider) } private func isOpenAIWebSubviewMenu(_ menu: NSMenu) -> Bool { @@ -1334,7 +1582,7 @@ extension StatusItemController { } } - private func refreshHostedSubviewHeights(in menu: NSMenu) { + func refreshHostedSubviewHeights(in menu: NSMenu) { let width = self.renderedMenuWidth(for: menu) for item in menu.items { @@ -1346,102 +1594,6 @@ extension StatusItemController { } } - func menuCardModel( - for provider: UsageProvider?, - snapshotOverride: UsageSnapshot? = nil, - errorOverride: String? = nil) -> UsageMenuCardView.Model? - { - let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex - let metadata = self.store.metadata(for: target) - - let snapshot = snapshotOverride ?? self.store.snapshot(for: target) - let surface: CodexConsumerProjection.Surface = if snapshotOverride != nil || errorOverride != nil { - .overrideCard - } else { - .liveCard - } - let now = Date() - let codexProjection = self.store.codexConsumerProjectionIfNeeded( - for: target, - surface: surface, - snapshotOverride: snapshotOverride, - errorOverride: errorOverride, - now: now) - let credits: CreditsSnapshot? - let creditsError: String? - let dashboard: OpenAIDashboardSnapshot? - let dashboardError: String? - let tokenSnapshot: CostUsageTokenSnapshot? - let tokenError: String? - if let codexProjection { - credits = codexProjection.credits?.snapshot - creditsError = codexProjection.credits?.userFacingError - dashboard = nil - dashboardError = codexProjection.userFacingErrors.dashboard - if surface == .liveCard { - tokenSnapshot = self.store.tokenSnapshot(for: target) - tokenError = self.store.tokenError(for: target) - } else { - tokenSnapshot = nil - tokenError = nil - } - } else if target == .claude || target == .vertexai, snapshotOverride == nil { - credits = nil - creditsError = nil - dashboard = nil - dashboardError = nil - tokenSnapshot = self.store.tokenSnapshot(for: target) - tokenError = self.store.tokenError(for: target) - } else { - credits = nil - creditsError = nil - dashboard = nil - dashboardError = nil - tokenSnapshot = nil - tokenError = nil - } - - let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil - let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - // Abacus uses primary for monthly credits (no secondary window) - let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) - { - self.store.weeklyPace(provider: target, window: weekly, now: now) - } else { - paceWindow.flatMap { window in - self.store.weeklyPace(provider: target, window: window, now: now) - } - } - let input = UsageMenuCardView.Model.Input( - provider: target, - metadata: metadata, - snapshot: snapshot, - codexProjection: codexProjection, - credits: credits, - creditsError: creditsError, - dashboard: dashboard, - dashboardError: dashboardError, - tokenSnapshot: tokenSnapshot, - tokenError: tokenError, - account: self.store.accountInfo(for: target), - isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), - lastError: errorOverride - ?? codexProjection?.userFacingErrors.usage - ?? self.store.userFacingError(for: target), - usageBarsShowUsed: self.settings.usageBarsShowUsed, - resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, - tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), - showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, - sourceLabel: sourceLabel, - kiloAutoMode: kiloAutoMode, - hidePersonalInfo: self.settings.hidePersonalInfo, - weeklyPace: weeklyPace, - now: now) - return UsageMenuCardView.Model.make(input) - } - @objc private func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index 44d2c35c1..c4becbc97 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -8,7 +8,9 @@ extension StatusItemController { case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil) case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) + case .changelog: (#selector(self.openChangelog), nil) case .addCodexAccount: (#selector(self.addManagedCodexAccountFromMenu(_:)), nil) + case let .addProviderAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .requestCodexSystemPromotion(managedAccountID): (#selector(self.requestCodexSystemPromotionFromMenu(_:)), managedAccountID.uuidString) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift new file mode 100644 index 000000000..7e7247cee --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -0,0 +1,124 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func menuCardModel( + for provider: UsageProvider?, + snapshotOverride: UsageSnapshot? = nil, + errorOverride: String? = nil, + forceOverrideCard: Bool = false, + accountOverride: AccountInfo? = nil) -> UsageMenuCardView.Model? + { + let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex + let metadata = self.store.metadata(for: target) + + let usesOverrideCard = forceOverrideCard || snapshotOverride != nil || errorOverride != nil + let surface: CodexConsumerProjection.Surface = if usesOverrideCard { + .overrideCard + } else { + .liveCard + } + // Override cards belong to a specific account/context. Never fall back to + // provider-level live data here; that can belong to a different account. + let snapshot: UsageSnapshot? = if surface == .overrideCard { + snapshotOverride + } else { + snapshotOverride ?? self.store.snapshot(for: target) + } + let now = Date() + let codexProjection = self.store.codexConsumerProjectionIfNeeded( + for: target, + surface: surface, + snapshotOverride: snapshotOverride, + errorOverride: errorOverride, + now: now) + let credits: CreditsSnapshot? + let creditsError: String? + let dashboard: OpenAIDashboardSnapshot? + let dashboardError: String? + let tokenSnapshot: CostUsageTokenSnapshot? + let tokenError: String? + if let codexProjection { + credits = codexProjection.credits?.snapshot + creditsError = codexProjection.credits?.userFacingError + dashboard = nil + dashboardError = codexProjection.userFacingErrors.dashboard + if surface == .liveCard { + tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenError = self.store.tokenError(for: target) + } else { + tokenSnapshot = nil + tokenError = nil + } + } else if target == .claude || target == .vertexai || target == .bedrock, snapshotOverride == nil { + credits = nil + creditsError = nil + dashboard = nil + dashboardError = nil + tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenError = self.store.tokenError(for: target) + } else { + credits = nil + creditsError = nil + dashboard = nil + dashboardError = nil + tokenSnapshot = nil + tokenError = nil + } + + let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil + let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto + // Abacus uses primary for monthly credits (no secondary window) + let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary + let weeklyPace = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) + { + self.store.weeklyPace(provider: target, window: weekly, now: now) + } else { + paceWindow.flatMap { window in + self.store.weeklyPace(provider: target, window: window, now: now) + } + } + let input = UsageMenuCardView.Model.Input( + provider: target, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: credits, + creditsError: creditsError, + dashboard: dashboard, + dashboardError: dashboardError, + tokenSnapshot: tokenSnapshot, + tokenError: tokenError, + account: accountOverride ?? self.store.accountInfo(for: target), + isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), + lastError: errorOverride + ?? codexProjection?.userFacingErrors.usage + ?? self.store.userFacingError(for: target), + usageBarsShowUsed: self.settings.usageBarsShowUsed, + resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, + tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), + showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + sourceLabel: sourceLabel, + kiloAutoMode: kiloAutoMode, + hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, + weeklyPace: weeklyPace, + quotaWarningThresholds: [ + .session: self.quotaWarningMarkerThresholds(provider: target, window: .session), + .weekly: self.quotaWarningMarkerThresholds(provider: target, window: .weekly), + ], + now: now) + return UsageMenuCardView.Model.make(input) + } + + func accountInfo(for account: CodexVisibleAccount) -> AccountInfo { + AccountInfo(email: account.email, plan: account.workspaceLabel) + } + + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + guard self.settings.quotaWarningMarkersVisible else { return [] } + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } + return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuContexts.swift b/Sources/CodexBar/StatusItemController+MenuContexts.swift new file mode 100644 index 000000000..109137428 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuContexts.swift @@ -0,0 +1,34 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + struct OpenAIWebContext { + let hasUsageBreakdown: Bool + let hasCreditsHistory: Bool + let hasCostHistory: Bool + let canShowBuyCredits: Bool + let hasOpenAIWebMenuItems: Bool + } + + struct MenuCardContext { + let currentProvider: UsageProvider + let selectedProvider: UsageProvider? + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + } + + struct MenuRebuildContext { + let enabledProviders: [UsageProvider] + let includesOverview: Bool + let switcherSelection: ProviderSwitcherSelection? + let currentProvider: UsageProvider + let selectedProvider: UsageProvider? + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 418d4e9d2..d2e8d33f2 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -168,3 +168,119 @@ struct MenuCardSectionContainerView: View { } } } + +@MainActor +final class PersistentMenuActionItemView: NSView, MenuCardHighlighting { + static let rowHeight: CGFloat = 28 + + private let backgroundView = NSView() + private let imageView = NSImageView() + private let titleField: NSTextField + private let shortcutField: NSTextField? + private let onClick: () -> Void + + override var intrinsicContentSize: NSSize { + NSSize(width: self.frame.width > 0 ? self.frame.width : NSView.noIntrinsicMetric, height: Self.rowHeight) + } + + override var fittingSize: NSSize { + NSSize(width: self.frame.width, height: Self.rowHeight) + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(NSSize(width: newSize.width, height: Self.rowHeight)) + } + + init( + title: String, + systemImageName: String?, + shortcutText: String?, + width: CGFloat, + onClick: @escaping () -> Void) + { + self.titleField = NSTextField(labelWithString: title) + self.shortcutField = shortcutText.map(NSTextField.init(labelWithString:)) + self.onClick = onClick + super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight))) + self.setupView(systemImageName: systemImageName) + self.setHighlighted(false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseUp(with event: NSEvent) { + guard event.type == .leftMouseUp else { return } + self.onClick() + } + + func setHighlighted(_ highlighted: Bool) { + let primaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor + let secondaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor + self.backgroundView.isHidden = !highlighted + self.titleField.textColor = primaryColor + self.shortcutField?.textColor = secondaryColor + self.imageView.contentTintColor = primaryColor + } + + private func setupView(systemImageName: String?) { + self.backgroundView.wantsLayer = true + self.backgroundView.layer?.cornerRadius = 6 + self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor + self.backgroundView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.backgroundView) + + if let systemImageName, + let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil) + { + image.isTemplate = true + image.size = NSSize(width: 16, height: 16) + self.imageView.image = image + } + self.imageView.translatesAutoresizingMaskIntoConstraints = false + + self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize) + self.titleField.lineBreakMode = .byTruncatingTail + self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + self.titleField.translatesAutoresizingMaskIntoConstraints = false + + let spacer = NSView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let stack = NSStackView() + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(self.imageView) + stack.addArrangedSubview(self.titleField) + stack.addArrangedSubview(spacer) + if let shortcutField { + shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) + shortcutField.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(shortcutField) + } + self.addSubview(stack) + + NSLayoutConstraint.activate([ + self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), + self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), + self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2), + self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2), + + self.imageView.widthAnchor.constraint(equalToConstant: 18), + self.imageView.heightAnchor.constraint(equalToConstant: 18), + + stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12), + stack.centerYAnchor.constraint(equalTo: self.centerYAnchor), + ]) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift new file mode 100644 index 000000000..64c37b553 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -0,0 +1,23 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + updates() + } + + func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + self.providerSwitcherUpdateToken &+= 1 + let updateToken = self.providerSwitcherUpdateToken + Task { @MainActor [weak self, weak menu] in + await Task.yield() + guard let self, let menu else { return } + guard self.providerSwitcherUpdateToken == updateToken else { return } + self.rebuildOpenMenuIfStillVisible(menu, provider: provider) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift new file mode 100644 index 000000000..033710b01 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -0,0 +1,67 @@ +import AppKit + +extension StatusItemController { + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { + let measuredWidth = ceil(menu.size.width) + return max(measuredWidth, Self.menuCardBaseWidth) + } + + func refreshOpenMenusIfNeeded() { + guard Self.menuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded(allowsParentRebuild: false) + } + + func refreshOpenMenusForStructureChange() { + self.refreshOpenMenusAllowingParentRebuild() + } + + func refreshOpenMenusAllowingParentRebuild() { + guard Self.menuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + } + + private func refreshOpenMenusIfNeeded(allowsParentRebuild: Bool) { + var orphanedKeys: [ObjectIdentifier] = [] + let hasOpenHostedSubviewMenu = self.hasOpenHostedSubviewMenu() + for (key, menu) in self.openMenus { + guard key == ObjectIdentifier(menu) else { + orphanedKeys.append(key) + continue + } + self.refreshOpenMenuIfNeeded( + menu, + allowsParentRebuild: allowsParentRebuild, + hasOpenHostedSubviewMenu: hasOpenHostedSubviewMenu) + } + self.removeOrphanedOpenMenuEntries(orphanedKeys) + } + + private func refreshOpenMenuIfNeeded( + _ menu: NSMenu, + allowsParentRebuild: Bool, + hasOpenHostedSubviewMenu: Bool) + { + if self.isHostedSubviewMenu(menu) { + self.refreshHostedSubviewHeights(in: menu) + return + } + guard allowsParentRebuild else { return } + guard !hasOpenHostedSubviewMenu else { return } + guard self.menuNeedsRefresh(menu) else { return } + + let provider = self.menuProvider(for: menu) + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + + private func removeOrphanedOpenMenuEntries(_ keys: [ObjectIdentifier]) { + for key in keys { + self.openMenus.removeValue(forKey: key) + self.menuRefreshTasks.removeValue(forKey: key)?.cancel() + self.menuProviders.removeValue(forKey: key) + self.menuVersions.removeValue(forKey: key) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 3e8c6c3cc..5e9cc13a4 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -15,7 +15,9 @@ extension ProviderSwitcherSelection { struct OverviewMenuCardRowView: View { let model: UsageMenuCardView.Model + let storageText: String? let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -30,12 +32,28 @@ struct OverviewMenuCardRowView: View { bottomPadding: 6, width: self.width) } + if let storageText { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("Storage:") + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Text(storageText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, self.hasUsageBlock ? 0 : 8) + .padding(.bottom, 6) + .frame(width: self.width, alignment: .leading) + } } .frame(width: self.width, alignment: .leading) } private var hasUsageBlock: Bool { - !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil + self.model.hasUsageContent } } @@ -46,16 +64,107 @@ struct OpenAIWebMenuItems { let canShowBuyCredits: Bool } -struct TokenAccountMenuDisplay { +struct TokenAccountMenuDisplay: Equatable { let provider: UsageProvider let accounts: [ProviderTokenAccount] let snapshots: [TokenAccountUsageSnapshot] let activeIndex: Int - let showAll: Bool - let showSwitcher: Bool + let layout: MultiAccountMenuLayout + + var showAll: Bool { + self.layout == .stacked + } + + var showSwitcher: Bool { + self.layout == .segmented + } + + static func == (lhs: TokenAccountMenuDisplay, rhs: TokenAccountMenuDisplay) -> Bool { + lhs.provider == rhs.provider && + lhs.accountIdentity == rhs.accountIdentity && + lhs.activeIndex == rhs.activeIndex && + lhs.layout == rhs.layout && + lhs.snapshotIdentity == rhs.snapshotIdentity + } + + private var accountIdentity: [AccountIdentity] { + self.accounts.map { account in + AccountIdentity( + id: account.id, + label: account.label, + externalIdentifier: account.externalIdentifier, + organizationID: account.organizationID) + } + } + + private var snapshotIdentity: [SnapshotIdentity] { + self.snapshots.map { snapshot in + SnapshotIdentity( + id: snapshot.id, + hasSnapshot: snapshot.snapshot != nil, + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } + } + + private struct AccountIdentity: Equatable { + let id: UUID + let label: String + let externalIdentifier: String? + let organizationID: String? + } + + private struct SnapshotIdentity: Equatable { + let id: UUID + let hasSnapshot: Bool + let error: String? + let sourceLabel: String? + } } struct CodexAccountMenuDisplay: Equatable { let accounts: [CodexVisibleAccount] + let snapshots: [CodexAccountUsageSnapshot] let activeVisibleAccountID: String? + let layout: MultiAccountMenuLayout + + var showAll: Bool { + self.layout == .stacked + } + + var showSwitcher: Bool { + self.layout == .segmented + } + + var workspaceSections: [CodexAccountWorkspaceSection] { + self.accounts.codexWorkspaceSections() + } + + var showsWorkspaceGroups: Bool { + Set(self.workspaceSections.map(\.title)).count > 1 + } + + static func == (lhs: CodexAccountMenuDisplay, rhs: CodexAccountMenuDisplay) -> Bool { + lhs.accounts == rhs.accounts && + lhs.activeVisibleAccountID == rhs.activeVisibleAccountID && + lhs.layout == rhs.layout && + lhs.snapshotIdentity == rhs.snapshotIdentity + } + + private var snapshotIdentity: [SnapshotIdentity] { + self.snapshots.map { snapshot in + SnapshotIdentity( + id: snapshot.id, + hasSnapshot: snapshot.snapshot != nil, + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } + } + + private struct SnapshotIdentity: Equatable { + let id: String + let hasSnapshot: Bool + let error: String? + let sourceLabel: String? + } } diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift new file mode 100644 index 000000000..bfefcf78d --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -0,0 +1,30 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func makeOverviewRowSubmenu( + provider: UsageProvider, + model: UsageMenuCardView.Model, + width: CGFloat) -> NSMenu? + { + if provider == .openai, + let submenu = self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) + { + return submenu + } + if provider == .zai, + let submenu = self.makeZaiUsageDetailsSubmenu(snapshot: self.store.snapshot(for: provider)) + { + return submenu + } + if model.tokenUsage != nil, + let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) + { + return submenu + } + if let submenu = self.makeUsageHistorySubmenu(provider: provider, width: width) { + return submenu + } + return self.makeStorageBreakdownSubmenu(provider: provider, width: width) + } +} diff --git a/Sources/CodexBar/StatusItemController+ProviderNavigation.swift b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift new file mode 100644 index 000000000..8a1091938 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ProviderNavigation.swift @@ -0,0 +1,50 @@ +import CodexBarCore + +extension StatusItemController { + func navigateProviderSwitcher(_ direction: StatusItemMenuProviderNavigationDirection) { + guard self.shouldMergeIcons else { return } + let enabledProviders = self.store.enabledProvidersForDisplay() + guard enabledProviders.count > 1 else { return } + + let includesOverview = !self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).isEmpty + var selections = enabledProviders.map(ProviderSwitcherSelection.provider) + if includesOverview { + selections.insert(.overview, at: 0) + } + + let current: ProviderSwitcherSelection = if includesOverview, + self.settings.mergedMenuLastSelectedWasOverview + { + .overview + } else { + .provider(self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex) + } + guard let currentIndex = selections.firstIndex(of: current) else { return } + + let delta = direction == .next ? 1 : -1 + let nextIndex = (currentIndex + delta + selections.count) % selections.count + let selection = selections[nextIndex] + switch selection { + case .overview: + self.settings.mergedMenuLastSelectedWasOverview = true + self.lastMenuProvider = self.navigationResolvedProvider(enabledProviders: enabledProviders) ?? .codex + case let .provider(provider): + self.settings.mergedMenuLastSelectedWasOverview = false + self.selectedMenuProvider = provider + self.lastMenuProvider = provider + } + self.lastMergedSwitcherSelection = selection + self.invalidateMenus(refreshOpenMenus: true) + self.applyIcon(phase: nil) + } + + private func navigationResolvedProvider(enabledProviders: [UsageProvider]) -> UsageProvider? { + if enabledProviders.isEmpty { return .codex } + if let selected = self.selectedMenuProvider, enabledProviders.contains(selected) { + return selected + } + return enabledProviders.first(where: { self.store.isProviderAvailable($0) }) ?? enabledProviders.first + } +} diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 74bf8fae7..a5fa0ae16 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore enum ProviderSwitcherSelection: Equatable { case overview @@ -13,9 +14,9 @@ final class ProviderSwitcherView: NSView { let title: String } - private struct WeeklyIndicator { - let track: NSView - let fill: NSView + private struct QuotaIndicator { + let badge: NSView + let fillWidthConstraint: NSLayoutConstraint } private let segments: [Segment] @@ -23,7 +24,7 @@ final class ProviderSwitcherView: NSView { private let showsIcons: Bool private let weeklyRemainingProvider: (UsageProvider) -> Double? private var buttons: [NSButton] = [] - private var weeklyIndicators: [ObjectIdentifier: WeeklyIndicator] = [:] + private var quotaIndicators: [ObjectIdentifier: QuotaIndicator] = [:] private var hoverTrackingArea: NSTrackingArea? private var segmentWidths: [CGFloat] = [] private let selectedBackground = NSColor.controlAccentColor.cgColor @@ -36,6 +37,7 @@ final class ProviderSwitcherView: NSView { private let rowHeight: CGFloat private var preferredWidth: CGFloat = 0 private var hoveredButtonTag: Int? + private var pressedButtonTag: Int? private let lightModeOverlayLayer = CALayer() init( @@ -162,7 +164,7 @@ final class ProviderSwitcherView: NSView { case .overview: nil } - self.addWeeklyIndicator(to: button, selection: segment.selection, remainingPercent: remaining) + self.addQuotaIndicator(to: button, selection: segment.selection, remainingPercent: remaining) button.bezelStyle = .regularSquare button.isBordered = false button.controlSize = .small @@ -223,7 +225,12 @@ final class ProviderSwitcherView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - self.window?.acceptsMouseMovedEvents = true + if let window = self.window { + window.acceptsMouseMovedEvents = true + } else if self.hoveredButtonTag != nil { + self.hoveredButtonTag = nil + self.updateButtonStyles() + } } override func updateTrackingAreas() { @@ -261,6 +268,62 @@ final class ProviderSwitcherView: NSView { self.updateButtonStyles() } + // MARK: - Click handling + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + // NSMenu's tracking run loop occasionally drops NSButton target-action dispatch when the + // menu is rebuilt under the cursor (e.g. after switching back from a provider tab to + // Overview). The overrides in this section hit-test the parent view, then drive + // selection from mouseDown/mouseUp here so the click never has to round-trip through + // NSButton's tracking loop. See issue #867. + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let descendant = super.hitTest(point) + if descendant != nil, descendant !== self { + // Swallow any hit on a child NSButton so its tracking loop never sees the click. + return self + } + return descendant + } + + override func mouseDown(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.pressedButtonTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag + } + + override func mouseUp(with event: NSEvent) { + defer { self.pressedButtonTag = nil } + guard let pressedTag = self.pressedButtonTag else { return } + let location = self.convert(event.locationInWindow, from: nil) + guard let releasedTag = self.buttons.first(where: { $0.frame.contains(location) })?.tag, + releasedTag == pressedTag, + self.segments.indices.contains(pressedTag) + else { + return + } + self.applySelection(at: pressedTag) + } + + private func applySelection(at index: Int) { + let selection = self.segments[index].selection + self.updateSelection(selection) + self.onSelect(selection) + } + + #if DEBUG + /// Simulates the runtime click path (mouseDown → mouseUp on this view) that the menu uses + /// in production, bypassing `NSButton.performClick`. Tests use this to cover the path that + /// regressed in issue #867. + @discardableResult + func _test_simulateRuntimeClick(buttonTag: Int) -> Bool { + guard self.segments.indices.contains(buttonTag) else { return false } + self.applySelection(at: buttonTag) + return true + } + #endif + private func applyLayout( outerPadding: CGFloat, minimumGap: CGFloat, @@ -508,17 +571,25 @@ final class ProviderSwitcherView: NSView { NSSize(width: self.preferredWidth, height: self.frame.size.height) } + func updateSelection(_ selection: ProviderSwitcherSelection) { + for (index, button) in self.buttons.enumerated() { + let isSelected = self.segments.indices.contains(index) && self.segments[index].selection == selection + button.state = isSelected ? .on : .off + } + self.updateButtonStyles() + } + @objc private func handleSelection(_ sender: NSButton) { let index = sender.tag guard self.segments.indices.contains(index) else { return } - for (idx, button) in self.buttons.enumerated() { - button.state = (idx == index) ? .on : .off - } - self.updateButtonStyles() - self.onSelect(self.segments[index].selection) + self.applySelection(at: index) } private func updateButtonStyles() { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + for button in self.buttons { let isSelected = button.state == .on let isHovered = self.hoveredButtonTag == button.tag @@ -530,12 +601,29 @@ final class ProviderSwitcherView: NSView { } else { self.unselectedBackground } - self.updateWeeklyIndicatorVisibility(for: button) + self.updateQuotaIndicatorVisibility(for: button) (button as? StackedToggleButton)?.setContentTintColor(button.contentTintColor) (button as? InlineIconToggleButton)?.setContentTintColor(button.contentTintColor) } } + #if DEBUG + func _test_buttonFrames() -> [NSRect] { + self.buttons.map(\.frame) + } + + func _test_setHoveredButtonTag(_ tag: Int?) { + self.hoveredButtonTag = tag + self.updateButtonStyles() + } + + func _test_quotaIndicatorFillWidths() -> [CGFloat] { + self.buttons.compactMap { button in + self.quotaIndicators[ObjectIdentifier(button)]?.fillWidthConstraint.constant + } + } + #endif + private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } @@ -727,59 +815,74 @@ final class ProviderSwitcherView: NSView { return newImage } - private func addWeeklyIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { + private func addQuotaIndicator(to view: NSView, selection: ProviderSwitcherSelection, remainingPercent: Double?) { guard let remainingPercent else { return } - - let track = NSView() - track.wantsLayer = true - track.layer?.backgroundColor = NSColor.tertiaryLabelColor.withAlphaComponent(0.22).cgColor - track.layer?.cornerRadius = 2 - track.layer?.masksToBounds = true - track.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(track) + let indicatorWidth: CGFloat = 16 + let fillWidth = Self.quotaIndicatorFillWidth( + remainingPercent: remainingPercent, + totalWidth: indicatorWidth) + + let badge = NSView() + badge.wantsLayer = true + badge.layer?.backgroundColor = NSColor.secondaryLabelColor.withAlphaComponent(0.18).cgColor + badge.layer?.cornerRadius = 1.5 + badge.layer?.masksToBounds = true + badge.translatesAutoresizingMaskIntoConstraints = false let fill = NSView() fill.wantsLayer = true - fill.layer?.backgroundColor = Self.weeklyIndicatorColor(for: selection).cgColor - fill.layer?.cornerRadius = 2 + fill.layer?.backgroundColor = Self.quotaIndicatorColor( + for: selection, + remainingPercent: remainingPercent).cgColor + fill.layer?.cornerRadius = 1.5 + fill.layer?.masksToBounds = true fill.translatesAutoresizingMaskIntoConstraints = false - track.addSubview(fill) - - let ratio = CGFloat(max(0, min(1, remainingPercent / 100))) + badge.addSubview(fill) + view.addSubview(badge) + let fillWidthConstraint = fill.widthAnchor.constraint(equalToConstant: fillWidth) NSLayoutConstraint.activate([ - track.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6), - track.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), - track.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -1), - track.heightAnchor.constraint(equalToConstant: 4), - fill.leadingAnchor.constraint(equalTo: track.leadingAnchor), - fill.topAnchor.constraint(equalTo: track.topAnchor), - fill.bottomAnchor.constraint(equalTo: track.bottomAnchor), + badge.topAnchor.constraint(equalTo: view.topAnchor, constant: 3), + badge.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6), + badge.widthAnchor.constraint(equalToConstant: indicatorWidth), + badge.heightAnchor.constraint(equalToConstant: 3), + fill.leadingAnchor.constraint(equalTo: badge.leadingAnchor), + fill.topAnchor.constraint(equalTo: badge.topAnchor), + fill.bottomAnchor.constraint(equalTo: badge.bottomAnchor), + fillWidthConstraint, ]) - fill.widthAnchor.constraint(equalTo: track.widthAnchor, multiplier: ratio).isActive = true - - self.weeklyIndicators[ObjectIdentifier(view)] = WeeklyIndicator(track: track, fill: fill) - self.updateWeeklyIndicatorVisibility(for: view) + self.quotaIndicators[ObjectIdentifier(view)] = QuotaIndicator( + badge: badge, + fillWidthConstraint: fillWidthConstraint) + self.updateQuotaIndicatorVisibility(for: view) } - private func updateWeeklyIndicatorVisibility(for view: NSView) { - guard let indicator = self.weeklyIndicators[ObjectIdentifier(view)] else { return } + private func updateQuotaIndicatorVisibility(for view: NSView) { + guard let indicator = self.quotaIndicators[ObjectIdentifier(view)] else { return } let isSelected = (view as? NSButton)?.state == .on - indicator.track.isHidden = isSelected - indicator.fill.isHidden = isSelected + indicator.badge.isHidden = isSelected } - private static func weeklyIndicatorColor(for selection: ProviderSwitcherSelection) -> NSColor { + private static func quotaIndicatorColor( + for selection: ProviderSwitcherSelection, + remainingPercent _: Double) -> NSColor + { switch selection { case let .provider(provider): let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color - return NSColor(deviceRed: color.red, green: color.green, blue: color.blue, alpha: 1) + return NSColor(deviceRed: color.red, green: color.green, blue: color.blue, alpha: 0.7) case .overview: - return NSColor.secondaryLabelColor + return NSColor.secondaryLabelColor.withAlphaComponent(0.7) } } + private static func quotaIndicatorFillWidth(remainingPercent: Double, totalWidth: CGFloat) -> CGFloat { + let clamped = min(100, max(0, remainingPercent)) + guard clamped > 0 else { return 0 } + return max(3, totalWidth * CGFloat(clamped / 100)) + } + private static func overviewIcon() -> NSImage { if let symbol = NSImage(systemSymbolName: "square.grid.2x2", accessibilityDescription: nil) { return symbol @@ -794,9 +897,10 @@ final class ProviderSwitcherView: NSView { final class TokenAccountSwitcherView: NSView { private let accounts: [ProviderTokenAccount] - private let onSelect: (Int) -> Void + private let onSelect: (Int) -> Task? private var selectedIndex: Int private var buttons: [NSButton] = [] + private let preferredSize: NSSize private let rowSpacing: CGFloat = 4 private let rowHeight: CGFloat = 26 private let selectedBackground = NSColor.controlAccentColor.cgColor @@ -804,13 +908,19 @@ final class TokenAccountSwitcherView: NSView { private let selectedTextColor = NSColor.white private let unselectedTextColor = NSColor.secondaryLabelColor - init(accounts: [ProviderTokenAccount], selectedIndex: Int, width: CGFloat, onSelect: @escaping (Int) -> Void) { + init( + accounts: [ProviderTokenAccount], + selectedIndex: Int, + width: CGFloat, + onSelect: @escaping (Int) -> Task?) + { self.accounts = accounts self.onSelect = onSelect self.selectedIndex = min(max(selectedIndex, 0), max(0, accounts.count - 1)) let useTwoRows = accounts.count > 3 let rows = useTwoRows ? 2 : 1 let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0) + self.preferredSize = NSSize(width: width, height: height) super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) self.wantsLayer = true self.buildButtons(useTwoRows: useTwoRows) @@ -822,6 +932,14 @@ final class TokenAccountSwitcherView: NSView { nil } + override var intrinsicContentSize: NSSize { + self.preferredSize + } + + override var fittingSize: NSSize { + self.preferredSize + } + private func buildButtons(useTwoRows: Bool) { let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count let rows: [[ProviderTokenAccount]] = { @@ -833,7 +951,7 @@ final class TokenAccountSwitcherView: NSView { let stack = NSStackView() stack.orientation = .vertical - stack.alignment = .centerX + stack.alignment = .width stack.spacing = self.rowSpacing stack.translatesAutoresizingMaskIntoConstraints = false @@ -857,6 +975,8 @@ final class TokenAccountSwitcherView: NSView { button.setButtonType(.toggle) button.controlSize = .small button.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + button.cell?.lineBreakMode = account.displayName.contains("@") ? .byTruncatingMiddle : .byTruncatingTail + button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) button.wantsLayer = true button.layer?.cornerRadius = 6 row.addArrangedSubview(button) @@ -865,6 +985,7 @@ final class TokenAccountSwitcherView: NSView { } stack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } self.addSubview(stack) @@ -888,19 +1009,35 @@ final class TokenAccountSwitcherView: NSView { } @objc private func handleSelect(_ sender: NSButton) { - let index = sender.tag - guard index >= 0, index < self.accounts.count else { return } + _ = self.select(index: sender.tag) + } + + @discardableResult + private func select(index: Int) -> Task? { + guard index >= 0, index < self.accounts.count else { return nil } self.selectedIndex = index self.updateButtonStyles() - self.onSelect(index) + return self.onSelect(index) } + + #if DEBUG + func _test_select(index: Int) -> Task? { + guard let button = self.buttons.first(where: { $0.tag == index }) else { return nil } + return self.select(index: button.tag) + } + + func _test_buttonTitles() -> [String] { + self.buttons.map(\.title) + } + #endif } final class CodexAccountSwitcherView: NSView { private let accounts: [CodexVisibleAccount] - private let onSelect: (String) -> Void + private let onSelect: (CodexVisibleAccount) -> Void private var selectedAccountID: String private var buttons: [NSButton] = [] + private let preferredSize: NSSize private let rowSpacing: CGFloat = 4 private let rowHeight: CGFloat = 26 private let selectedBackground = NSColor.controlAccentColor.cgColor @@ -915,7 +1052,7 @@ final class CodexAccountSwitcherView: NSView { accounts: [CodexVisibleAccount], selectedAccountID: String?, width: CGFloat, - onSelect: @escaping (String) -> Void) + onSelect: @escaping (CodexVisibleAccount) -> Void) { self.accounts = accounts self.onSelect = onSelect @@ -923,6 +1060,7 @@ final class CodexAccountSwitcherView: NSView { let useTwoRows = accounts.count > 3 let rows = useTwoRows ? 2 : 1 let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0) + self.preferredSize = NSSize(width: width, height: height) super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) self.wantsLayer = true self.buildButtons(useTwoRows: useTwoRows) @@ -934,6 +1072,14 @@ final class CodexAccountSwitcherView: NSView { nil } + override var intrinsicContentSize: NSSize { + self.preferredSize + } + + override var fittingSize: NSSize { + self.preferredSize + } + private func buildButtons(useTwoRows: Bool) { let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count let rows: [[CodexVisibleAccount]] = { @@ -944,7 +1090,7 @@ final class CodexAccountSwitcherView: NSView { }() let stack = NSStackView() stack.orientation = .vertical - stack.alignment = .centerX + stack.alignment = .width stack.spacing = self.rowSpacing stack.translatesAutoresizingMaskIntoConstraints = false @@ -969,6 +1115,8 @@ final class CodexAccountSwitcherView: NSView { button.setButtonType(.toggle) button.controlSize = .small button.font = self.buttonFont + button.cell?.lineBreakMode = .byTruncatingTail + button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) button.wantsLayer = true button.layer?.cornerRadius = 6 row.addArrangedSubview(button) @@ -976,6 +1124,7 @@ final class CodexAccountSwitcherView: NSView { } stack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } self.addSubview(stack) @@ -1003,7 +1152,7 @@ final class CodexAccountSwitcherView: NSView { } guard let workspace = account.menuWorkspaceLabel else { - return self.truncateTail(account.email, toFit: availableTextWidth) + return self.truncateMiddle(account.email, toFit: availableTextWidth) } let separator = "|" @@ -1015,7 +1164,7 @@ final class CodexAccountSwitcherView: NSView { var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) func makeTitle() -> String { - let email = self.truncateTail(account.email, toFit: emailWidth) + let email = self.truncateMiddle(account.email, toFit: emailWidth) let workspace = self.truncateTail(workspace, toFit: workspaceWidth) return "\(email)\(separator)\(workspace)" } @@ -1023,7 +1172,7 @@ final class CodexAccountSwitcherView: NSView { var title = makeTitle() var attempts = 0 while self.textWidth(title) > availableTextWidth, attempts < 16 { - let emailText = self.truncateTail(account.email, toFit: emailWidth) + let emailText = self.truncateMiddle(account.email, toFit: emailWidth) let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) let emailRenderedWidth = self.textWidth(emailText) let workspaceRenderedWidth = self.textWidth(workspaceText) @@ -1069,6 +1218,52 @@ final class CodexAccountSwitcherView: NSView { return candidate + ellipsis } + private func truncateMiddle(_ text: String, toFit width: CGFloat) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return text } + if self.textWidth(trimmed) <= width { + return trimmed + } + + let ellipsis = "…" + let ellipsisWidth = self.textWidth(ellipsis) + guard ellipsisWidth < width else { return ellipsis } + + var prefix = "" + var suffix = "" + var prefixIndex = trimmed.startIndex + var suffixIndex = trimmed.endIndex + var best = ellipsis + var takeSuffixNext = true + + while prefixIndex < suffixIndex { + let nextPrefix: String + let nextSuffix: String + if takeSuffixNext { + let previousIndex = trimmed.index(before: suffixIndex) + nextPrefix = prefix + nextSuffix = String(trimmed[previousIndex]) + suffix + suffixIndex = previousIndex + } else { + nextPrefix = prefix + String(trimmed[prefixIndex]) + nextSuffix = suffix + prefixIndex = trimmed.index(after: prefixIndex) + } + + let candidate = nextPrefix + ellipsis + nextSuffix + if self.textWidth(candidate) > width { + break + } + + prefix = nextPrefix + suffix = nextSuffix + best = candidate + takeSuffixNext.toggle() + } + + return best + } + private func textWidth(_ text: String) -> CGFloat { let attributes: [NSAttributedString.Key: Any] = [.font: self.buttonFont] return ceil((text as NSString).size(withAttributes: attributes).width) @@ -1085,10 +1280,10 @@ final class CodexAccountSwitcherView: NSView { @objc private func handleSelect(_ sender: NSButton) { guard let accountID = sender.identifier?.rawValue else { return } - guard self.accounts.contains(where: { $0.id == accountID }) else { return } + guard let account = self.accounts.first(where: { $0.id == accountID }) else { return } self.selectedAccountID = accountID self.updateButtonStyles() - self.onSelect(accountID) + self.onSelect(account) } #if DEBUG @@ -1099,5 +1294,10 @@ final class CodexAccountSwitcherView: NSView { func _test_buttonToolTips() -> [String?] { self.buttons.map(\.toolTip) } + + func _test_selectAccount(id: String) { + guard let button = self.buttons.first(where: { $0.identifier?.rawValue == id }) else { return } + self.handleSelect(button) + } #endif } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 6cfed4206..73233cb38 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -11,7 +11,7 @@ private final class UsageHistoryMenuHostingView: NSHostingView Bool { - guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } + guard let submenu = self.makeUsageHistorySubmenu(provider: provider, width: width) else { return false } let item = self.makeMenuCardItem( HStack(spacing: 0) { Text("Subscription Utilization") @@ -31,9 +31,15 @@ extension StatusItemController { return true } - private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { + func makeUsageHistorySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { guard self.store.supportsPlanUtilizationHistory(for: provider) else { return nil } guard !self.store.shouldHidePlanUtilizationMenuItem(for: provider) else { return nil } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.usageHistoryChartID, + provider: provider, + width: width) + } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageHistoryChartID, provider: provider) } @@ -47,7 +53,7 @@ extension StatusItemController { if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true @@ -65,7 +71,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.view = hosting - chartItem.isEnabled = false + chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID submenu.addItem(chartItem) return true diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift new file mode 100644 index 000000000..ae341252a --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -0,0 +1,33 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + static let zaiHourlyUsageChartID = "zaiHourlyUsageChart" + + @discardableResult + func addZaiHourlyUsageMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard provider == .zai else { return false } + guard let snapshot = self.store.snapshot(for: provider), + snapshot.zaiUsage?.modelUsage != nil + else { return false } + let submenu = self.makeHostedSubviewPlaceholderMenu(chartID: Self.zaiHourlyUsageChartID, provider: provider) + let item = self.makeMenuCardItem( + HStack(spacing: 0) { + Text("Hourly Usage") + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 8) + }, + id: "zaiHourlyUsageSubmenu", + width: width, + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + menu.addItem(item) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 210fd8530..db0ec82fd 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -2,13 +2,13 @@ import AppKit import CodexBarCore import Observation import QuartzCore -import SwiftUI // MARK: - Status item controller (AppKit-hosted icons, SwiftUI popovers) @MainActor protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() + func runLoginFlowFromSettings(provider: UsageProvider) async func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? } @@ -22,7 +22,18 @@ extension StatusItemControlling { final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests - static var menuRefreshEnabled = !SettingsStore.isRunningTests + private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests + private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests + static let quotaWarningFlashDuration: TimeInterval = 60 + #if DEBUG + static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { + self.menuRefreshEnabled = enabled + } + + static func resetMenuRefreshEnabledForTesting() { + self.menuRefreshEnabled = self.defaultMenuRefreshEnabled + } + #endif typealias Factory = @MainActor ( UsageStore, SettingsStore, @@ -76,6 +87,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] #if DEBUG + var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var isReleasedForTesting = false var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? var _test_codexAmbientLoginRunnerOverride: (@MainActor (TimeInterval) async -> CodexLoginRunner.Result)? @@ -99,6 +112,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var blinkAmounts: [UsageProvider: CGFloat] = [:] var wiggleAmounts: [UsageProvider: CGFloat] = [:] var tiltAmounts: [UsageProvider: CGFloat] = [:] + var quotaWarningFlashUntil: [UsageProvider: Date] = [:] + var quotaWarningFlashTasks: [UsageProvider: Task] = [:] var blinkForceUntil: Date? var loginPhase: LoginPhase = .idle { didSet { @@ -112,6 +127,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var animationDriver: DisplayLinkDriver? var animationPhase: Double = 0 var animationPattern: LoadingPattern = .knightRider + var animationStartedAt: Date? private var lastConfigRevision: Int private var lastProviderOrder: [UsageProvider] private var lastMergeIcons: Bool @@ -129,13 +145,29 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastMergedSwitcherSelection: ProviderSwitcherSelection? /// Tracks the visible Codex account switcher contents for merged-menu smart updates. var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? + /// Tracks the visible token account switcher contents for merged-menu smart updates. + var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. + var providerSwitcherUpdateToken = 0 var lastAppliedMergedIconRenderSignature: String? + var lastAppliedProviderIconRenderSignatures: [UsageProvider: String] = [:] + var lastKnownScreenCount: Int + var pendingScreenChangePreviousCount: Int? + var screenChangeVisibilityTask: Task? let loginLogger = CodexBarLog.logger(LogCategories.login) + let menuLogger = CodexBarLog.logger(LogCategories.app) var selectedMenuProvider: UsageProvider? { get { self.settings.selectedMenuProvider } set { self.settings.selectedMenuProvider = newValue } } + private static func makeStatusItem(statusBar: NSStatusBar) -> NSStatusItem { + 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 + return item + } + struct BlinkState { var nextBlink: Date var blinkStart: Date? @@ -240,15 +272,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed self.statusBar = statusBar - 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 - self.statusItem = item + self.statusItem = Self.makeStatusItem(statusBar: statusBar) + self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() self.wireBindings() self.updateVisibility() self.updateIcons() + self.scheduleStartupStatusItemVisibilityCheck() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), @@ -259,6 +290,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleDebugBlinkNotification), name: .codexbarDebugBlinkNow, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleQuotaWarningPosted(_:)), + name: .codexbarQuotaWarningDidPost, + object: nil) if observeProviderConfigNotifications { NotificationCenter.default.addObserver( self, @@ -266,6 +302,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin name: .codexbarProviderConfigDidChange, object: nil) } + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleScreenParametersDidChange(_:)), + name: NSApplication.didChangeScreenParametersNotification, + object: nil) } convenience init( @@ -352,6 +393,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } @objc private func handleProviderConfigDidChange(_ notification: Notification) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let reason = notification.userInfo?["reason"] as? String ?? "unknown" if let source = notification.object as? SettingsStore, source !== self.settings @@ -365,6 +409,31 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.handleProviderConfigChange(reason: "notification:\(reason)") } + @objc private func handleQuotaWarningPosted(_ notification: Notification) { + guard let event = notification.object as? QuotaWarningPostedEvent else { return } + self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) + } + + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + guard let self else { return } + if let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= Date() + { + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + } + } + } + } + private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady @@ -394,13 +463,26 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - private func invalidateMenus() { + func invalidateMenus(refreshOpenMenus: Bool = false) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.menuContentVersion &+= 1 - // Don't refresh menus while they're open - wait until they close and reopen - // This prevents expensive rebuilds while user is navigating the menu - guard self.openMenus.isEmpty else { return } + 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 in + 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 } @@ -439,6 +521,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func handleSettingsChange(reason: String) { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() @@ -449,11 +534,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.updateVisibility() self.updateIcons() if shouldRefreshOpenMenus { - self.refreshOpenMenusIfNeeded() + self.refreshOpenMenusForStructureChange() } } private func updateIcons() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif // Avoid flicker: when an animation driver is active, store updates can call `updateIcons()` and // briefly overwrite the animated frame with the static (phase=nil) icon. let phase: Double? = self.needsMenuBarIconAnimation() ? self.animationPhase : nil @@ -465,6 +553,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin { return } + guard !self.isMergedMenuOpen else { + self.updateAnimationState() + self.updateBlinkingState() + return + } self.attachMenus() } else { UsageProvider.allCases.forEach { self.applyIcon(for: $0, phase: phase) } @@ -474,38 +567,61 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.updateBlinkingState() } + var isMergedMenuOpen: Bool { + guard let mergedMenu else { return false } + return self.openMenus[ObjectIdentifier(mergedMenu)] != nil + } + /// Lazily retrieves or creates a status item for the given provider func lazyStatusItem(for provider: UsageProvider) -> NSStatusItem { if let existing = self.statusItems[provider] { return existing } - let item = self.statusBar.statusItem(withLength: NSStatusItem.variableLength) - item.button?.imageScaling = .scaleNone + let item = Self.makeStatusItem(statusBar: self.statusBar) self.statusItems[provider] = item return item } + func recreateStatusItemsForVisibilityRecovery() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.statusItem.menu = nil + self.statusBar.removeStatusItem(self.statusItem) + self.statusItem = Self.makeStatusItem(statusBar: self.statusBar) + for provider in Array(self.statusItems.keys) { + self.removeProviderStatusItem(for: provider) + } + self.lastAppliedMergedIconRenderSignature = nil + self.lastAppliedProviderIconRenderSignatures.removeAll() + self.updateVisibility() + self.updateIcons() + } + private func updateVisibility() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif let anyEnabled = !self.store.enabledProvidersForDisplay().isEmpty let force = self.store.debugForceAnimation let mergeIcons = self.shouldMergeIcons if mergeIcons { self.statusItem.isVisible = anyEnabled || force - for item in self.statusItems.values { - item.isVisible = false + for provider in Array(self.statusItems.keys) { + self.removeProviderStatusItem(for: provider) } self.attachMenus() } else { self.statusItem.isVisible = false let fallback = self.fallbackProvider - for provider in UsageProvider.allCases { + for provider in self.settings.orderedProviders() { let isEnabled = self.isEnabled(provider) let shouldBeVisible = isEnabled || fallback == provider || force if shouldBeVisible { let item = self.lazyStatusItem(for: provider) item.isVisible = true - } else if let item = self.statusItems[provider] { - item.isVisible = false + } else { + self.removeProviderStatusItem(for: provider) } } self.attachMenus(fallback: fallback) @@ -525,8 +641,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func refreshMenusForLoginStateChange() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif self.invalidateMenus() if self.shouldMergeIcons { + guard !self.isMergedMenuOpen else { return } self.attachMenus() } else { self.attachMenus(fallback: self.fallbackProvider) @@ -567,25 +687,42 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } } else if let item = self.statusItems[provider] { - // Item exists but is no longer needed - clear its menu - if item.menu != nil { - item.menu = nil - } + item.menu = nil } } } private func rebuildProviderStatusItems() { - for item in self.statusItems.values { - self.statusBar.removeStatusItem(item) + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + let ordered = self.settings.orderedProviders() + let desired = Set(ordered) + for provider in Array(self.statusItems.keys) where !desired.contains(provider) { + self.removeProviderStatusItem(for: provider) + } + + guard !self.shouldMergeIcons else { return } + let fallback = self.fallbackProvider + let force = self.store.debugForceAnimation + for provider in ordered where self.isEnabled(provider) || fallback == provider || force { + _ = self.lazyStatusItem(for: provider) } - self.statusItems.removeAll(keepingCapacity: true) + } - for provider in self.settings.orderedProviders() { - let item = self.statusBar.statusItem(withLength: NSStatusItem.variableLength) - item.button?.imageScaling = .scaleNone - self.statusItems[provider] = item + private func removeProviderStatusItem(for provider: UsageProvider) { + if let menu = self.providerMenus.removeValue(forKey: provider) { + let menuID = ObjectIdentifier(menu) + self.menuProviders.removeValue(forKey: menuID) + self.menuVersions.removeValue(forKey: menuID) + self.openMenus.removeValue(forKey: menuID) + self.menuRefreshTasks.removeValue(forKey: menuID)?.cancel() } + + guard let item = self.statusItems.removeValue(forKey: provider) else { return } + item.menu = nil + self.lastAppliedProviderIconRenderSignatures.removeValue(forKey: provider) + self.statusBar.removeStatusItem(item) } func isVisible(_ provider: UsageProvider) -> Bool { @@ -608,6 +745,46 @@ 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 @@ -615,6 +792,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } self.blinkTask?.cancel() self.loginTask?.cancel() + self.screenChangeVisibilityTask?.cancel() + self.pendingScreenChangePreviousCount = nil NotificationCenter.default.removeObserver(self) } } diff --git a/Sources/CodexBar/StatusItemMenu.swift b/Sources/CodexBar/StatusItemMenu.swift new file mode 100644 index 000000000..e6b107001 --- /dev/null +++ b/Sources/CodexBar/StatusItemMenu.swift @@ -0,0 +1,54 @@ +import AppKit + +enum StatusItemMenuProviderNavigationDirection { + case previous + case next +} + +protocol StatusItemMenuPersistentActionDelegate: AnyObject { + func performPersistentRefreshAction() + func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) +} + +final class StatusItemMenu: NSMenu { + weak var persistentActionDelegate: StatusItemMenuPersistentActionDelegate? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if Self.isRefreshKeyEquivalent(event) { + self.persistentActionDelegate?.performPersistentRefreshAction() + return true + } + if let direction = Self.providerNavigationDirection(for: event), + self.items.first?.view is ProviderSwitcherView + { + self.persistentActionDelegate?.performProviderNavigation(direction) + return true + } + + return super.performKeyEquivalent(with: event) + } + + private nonisolated static func isRefreshKeyEquivalent(_ event: NSEvent) -> Bool { + guard event.type == .keyDown else { return false } + guard event.charactersIgnoringModifiers?.lowercased() == "r" else { return false } + + let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift]) + return relevantModifiers == .command + } + + private nonisolated static func providerNavigationDirection( + for event: NSEvent) -> StatusItemMenuProviderNavigationDirection? + { + guard event.type == .keyDown else { return nil } + let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard relevantModifiers.isEmpty else { return nil } + switch event.keyCode { + case 123: + return .previous + case 124: + return .next + default: + return nil + } + } +} diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift new file mode 100644 index 000000000..dbdbfda72 --- /dev/null +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -0,0 +1,222 @@ +import AppKit +import CodexBarCore +import SwiftUI + +struct StorageMenuCardSectionView: View { + let storageText: String + let topPadding: CGFloat + let bottomPadding: CGFloat + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Storage") + .font(.body) + .fontWeight(.medium) + Text(self.storageText) + .font(.caption) + } + .padding(.horizontal, 16) + .padding(.top, self.topPadding) + .padding(.bottom, self.bottomPadding) + .frame(width: self.width, alignment: .leading) + } +} + +struct StorageBreakdownMenuView: View { + let footprint: ProviderStorageFootprint + let width: CGFloat + let maxHeight: CGFloat + + init(footprint: ProviderStorageFootprint, width: CGFloat, maxHeight: CGFloat = 560) { + self.footprint = footprint + self.width = width + self.maxHeight = maxHeight + } + + var cleanupRecommendations: [ProviderStorageRecommendation] { + self.footprint.cleanupRecommendations + } + + var copyablePaths: [String] { + let recommendationPaths = self.cleanupRecommendations.map(\.path) + return self.visibleComponents.map(\.path) + recommendationPaths + } + + private var visibleComponents: [ProviderStorageFootprint.Component] { + Array(self.footprint.components.prefix(8)) + } + + private var maxBytes: Int64 { + max(self.visibleComponents.map(\.totalBytes).max() ?? 0, 1) + } + + var body: some View { + ScrollView(.vertical) { + self.content + } + .scrollIndicators(.visible) + .frame( + minWidth: self.width, + idealWidth: self.width, + maxWidth: self.width, + maxHeight: self.maxHeight, + alignment: .topLeading) + } + + private var content: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text("Storage") + .font(.body) + .fontWeight(.medium) + Text("Total: \(UsageFormatter.byteCountString(self.footprint.totalBytes))") + .font(.caption) + .foregroundStyle(.secondary) + } + + if self.visibleComponents.isEmpty { + Text("No local data found") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(self.visibleComponents) { component in + self.componentRow(component) + } + } + } + + if self.footprint.components.count > self.visibleComponents.count { + Text("\(self.footprint.components.count - self.visibleComponents.count) more items") + .font(.caption) + .foregroundStyle(.secondary) + } + if !self.cleanupRecommendations.isEmpty { + Divider() + .padding(.vertical, 2) + VStack(alignment: .leading, spacing: 8) { + Text("Cleanup ideas") + .font(.body) + .fontWeight(.medium) + ForEach(self.cleanupRecommendations) { recommendation in + self.recommendationRow(recommendation) + } + } + } + if !self.footprint.unreadablePaths.isEmpty { + Text("\(self.footprint.unreadablePaths.count) unreadable item(s) skipped") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(width: self.width, alignment: .leading) + } + + private func componentRow(_ component: ProviderStorageFootprint.Component) -> some View { + let fraction = CGFloat(max(0, min(1, Double(component.totalBytes) / Double(self.maxBytes)))) + return VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(component.path) + .font(.caption) + .lineLimit(1) + .truncationMode(.middle) + .help(component.path) + .layoutPriority(1) + Spacer() + StoragePathCopyButton(path: component.path) + Text(UsageFormatter.byteCountString(component.totalBytes)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(Color(nsColor: .quaternaryLabelColor)) + Capsule() + .fill(self.providerColor) + .frame(width: max(2, proxy.size.width * fraction)) + } + } + .frame(height: 5) + } + } + + private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(recommendation.title) + .font(.caption) + .fontWeight(.medium) + .lineLimit(1) + Spacer() + Text(UsageFormatter.byteCountString(recommendation.bytes)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + HStack(spacing: 4) { + Text(recommendation.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(recommendation.path) + .layoutPriority(1) + Spacer() + StoragePathCopyButton(path: recommendation.path) + } + Text(recommendation.consequence) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var providerColor: Color { + let color = ProviderDescriptorRegistry.descriptor(for: self.footprint.provider).branding.color + return Color(red: color.red, green: color.green, blue: color.blue) + } +} + +struct StoragePathCopyButton: View { + let path: String + + @State private var didCopy = false + @State private var resetTask: Task? + + var body: some View { + Button { + Self.copyToPasteboard(self.path) + withAnimation(.easeOut(duration: 0.12)) { + self.didCopy = true + } + self.resetTask?.cancel() + self.resetTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(0.9)) + withAnimation(.easeOut(duration: 0.2)) { + self.didCopy = false + } + } + } label: { + Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(self.didCopy ? "Copied" : "Copy path") + .accessibilityLabel(self.didCopy ? "Copied" : "Copy path") + } + + static func copyToPasteboard(_ path: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(path, forType: .string) + } +} diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ee0ccaa65..ad53befa3 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -34,6 +34,7 @@ struct UsageBreakdownChartMenuView: View { Text("No usage breakdown data.") .font(.footnote) .foregroundStyle(.secondary) + .accessibilityLabel("No usage breakdown data available.") } else { Chart { ForEach(model.points) { point in @@ -64,6 +65,11 @@ struct UsageBreakdownChartMenuView: View { } .chartLegend(.hidden) .frame(height: 130) + .accessibilityLabel("Usage breakdown chart") + .accessibilityValue( + model.points.isEmpty + ? "No data" + : "\(model.points.count) days of usage data across \(model.services.count) services") .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { @@ -146,7 +152,7 @@ struct UsageBreakdownChartMenuView: View { private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { - let sorted = breakdown + let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] diff --git a/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift b/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift new file mode 100644 index 000000000..66a9edf27 --- /dev/null +++ b/Sources/CodexBar/UsageMenuCardHeaderAndUsageSectionView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct UsageMenuCardHeaderAndUsageSectionView: View { + let model: UsageMenuCardView.Model + let bottomPadding: CGFloat + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + UsageMenuCardHeaderSectionView( + model: self.model, + showDivider: true, + width: self.width) + UsageMenuCardUsageSectionView( + model: self.model, + showBottomDivider: false, + bottomPadding: self.bottomPadding, + width: self.width) + } + .frame(width: self.width, alignment: .leading) + } +} diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 94d1ed565..fd245824d 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -9,6 +9,11 @@ enum UsagePaceText { let stage: UsagePace.Stage } + private enum DetailContext { + case session + case weekly + } + static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { @@ -20,7 +25,7 @@ enum UsagePaceText { static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { WeeklyDetail( leftLabel: self.detailLeftLabel(for: pace), - rightLabel: self.detailRightLabel(for: pace, now: now), + rightLabel: self.detailRightLabel(for: pace, context: .weekly, now: now), expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } @@ -37,13 +42,14 @@ enum UsagePaceText { } } - private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { + private static func detailRightLabel(for pace: UsagePace, context: DetailContext, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { etaLabel = "Lasts until reset" } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) - etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)" + let prefix = context == .session ? "Projected empty" : "Runs out" + etaLabel = etaText == "now" ? "\(prefix) now" : "\(prefix) in \(etaText)" } else { etaLabel = nil } @@ -70,4 +76,29 @@ enum UsagePaceText { let rounded = (percent / 5).rounded() * 5 return Int(rounded) } + + static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { + guard provider == .codex || provider == .claude else { 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 } + return pace + } + + static func sessionDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { + guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil } + return WeeklyDetail( + leftLabel: Self.detailLeftLabel(for: pace), + rightLabel: Self.detailRightLabel(for: pace, context: .session, now: now), + expectedUsedPercent: pace.expectedUsedPercent, + stage: pace.stage) + } + + 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 "Pace: \(detail.leftLabel)" + } } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index d2bc2d844..fbb344e32 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -17,6 +17,7 @@ struct UsageProgressBar: View { let accessibilityLabel: String let pacePercent: Double? let paceOnTop: Bool + let warningMarkerPercents: [Double] @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -25,13 +26,15 @@ struct UsageProgressBar: View { tint: Color, accessibilityLabel: String, pacePercent: Double? = nil, - paceOnTop: Bool = true) + paceOnTop: Bool = true, + warningMarkerPercents: [Double] = []) { self.percent = percent self.tint = tint self.accessibilityLabel = accessibilityLabel self.pacePercent = pacePercent self.paceOnTop = paceOnTop + self.warningMarkerPercents = warningMarkerPercents } private var clamped: Double { @@ -39,80 +42,83 @@ struct UsageProgressBar: View { } var body: some View { - GeometryReader { proxy in + // Draw the entire progress bar — track, fill, and pace-tip punch-out — in a single Canvas. + // A single Canvas uses Core Graphics internally and avoids the SwiftUI compositing modifiers + // (.compositingGroup, .blendMode) that trigger Metal/RenderBox shader compilation on macOS 26.x, + // which caused the status item icon to disappear (issue #805). + Canvas { context, size in let scale = max(self.displayScale, 1) - let fillWidth = proxy.size.width * self.clamped / 100 - let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100 - let tipWidth = max(25, proxy.size.height * 6.5) + let fillWidth = size.width * self.clamped / 100 + let paceWidth = size.width * Self.clampedPercent(self.pacePercent) / 100 + let tipWidth = max(25, size.height * 6.5) let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 - let needsPunchCompositing = showTip - let bar = ZStack(alignment: .leading) { - Capsule() - .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) - self.actualBar(width: fillWidth) - if showTip { - self.paceTip(width: tipWidth) - .offset(x: tipOffset) - } - } - .clipped() - if self.isHighlighted { - bar - .compositingGroup() - } else if needsPunchCompositing { - bar - .compositingGroup() - } else { - bar - } - } - .frame(height: 6) - .accessibilityLabel(self.accessibilityLabel) - .accessibilityValue("\(Int(self.clamped)) percent") - } + let markerPercents = self.warningMarkerPercents + .map(Self.clampedPercent) + .filter { $0 > 0 && $0 < 100 } - private func actualBar(width: CGFloat) -> some View { - Capsule() - .fill(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint)) - .frame(width: width) - .contentShape(Rectangle()) - .allowsHitTesting(false) - } - - private func paceTip(width: CGFloat) -> some View { - let isDeficit = self.paceOnTop == false - let useDeficitRed = isDeficit && self.isHighlighted == false - return GeometryReader { proxy in - let size = proxy.size + let cornerRadius = size.height / 2 + let cornerSize = CGSize(width: cornerRadius, height: cornerRadius) let rect = CGRect(origin: .zero, size: size) - let scale = max(self.displayScale, 1) - let stripes = Self.paceStripePaths(size: size, scale: scale) - let stripeColor: Color = if self.isHighlighted { - .white - } else if useDeficitRed { - .red - } else { - .green + + context.clip(to: Path(rect)) + + // Track + let trackPath = Path { p in p.addRoundedRect(in: rect, cornerSize: cornerSize) } + context.fill(trackPath, with: .color(MenuHighlightStyle.progressTrack(self.isHighlighted))) + + // Fill + if fillWidth > 0 { + let fillRect = CGRect(x: 0, y: 0, width: min(fillWidth, size.width), height: size.height) + let fillPath = Path { p in p.addRoundedRect(in: fillRect, cornerSize: cornerSize) } + context.fill( + fillPath, + with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))) } - ZStack { - Canvas { context, _ in - context.clip(to: Path(rect)) - context.fill(stripes.punched, with: .color(.white.opacity(0.9))) + if !markerPercents.isEmpty { + let markerWidth = max(1 / scale, 2) + let markerColor: Color = self.isHighlighted ? .white : .primary.opacity(0.72) + 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)) } - .blendMode(.destinationOut) + } - Canvas { context, _ in - context.clip(to: Path(rect)) - context.fill(stripes.center, with: .color(stripeColor)) + // Pace tip: punch-out + center stripe drawn within the canvas context using Core Graphics + // blend modes so no SwiftUI compositing modifier (.blendMode, .compositingGroup) is needed. + if showTip { + let isDeficit = self.paceOnTop == false + let useDeficitRed = isDeficit && self.isHighlighted == false + let stripeColor: Color = if self.isHighlighted { + .white + } else if useDeficitRed { + .red + } else { + .green } + + let tipSize = CGSize(width: tipWidth, height: size.height) + let stripes = Self.paceStripePaths(size: tipSize, scale: scale) + let shift = CGAffineTransform(translationX: tipOffset, y: 0) + + // Punch out of the accumulated track+fill pixels. + context.blendMode = .destinationOut + context.fill(stripes.punched.applying(shift), with: .color(.white.opacity(0.9))) + context.blendMode = .normal + + context.fill(stripes.center.applying(shift), with: .color(stripeColor)) } } - .frame(width: width) - .contentShape(Rectangle()) - .allowsHitTesting(false) + .frame(height: 6) + .accessibilityLabel(self.accessibilityLabel) + .accessibilityValue("\(Int(self.clamped)) percent") } private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) { diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 9c3c759e9..7c08e04d9 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -56,6 +56,10 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .elevenlabs: + return ElevenLabsUsageError.missingCredentials.errorDescription + case .deepseek: + return DeepSeekUsageError.missingCredentials.errorDescription case .perplexity: return PerplexityAPIError.missingToken.errorDescription case .minimax: diff --git a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift index 46f142347..b892b0b5a 100644 --- a/Sources/CodexBar/UsageStore+BackgroundRefresh.swift +++ b/Sources/CodexBar/UsageStore+BackgroundRefresh.swift @@ -12,6 +12,7 @@ extension UsageStore { self.accountSnapshots.removeValue(forKey: provider) self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil + self.providerStorageFootprints.removeValue(forKey: provider) self.failureGates[provider]?.reset() self.tokenFailureGates[provider]?.reset() self.statuses.removeValue(forKey: provider) diff --git a/Sources/CodexBar/UsageStore+ClaudeDebug.swift b/Sources/CodexBar/UsageStore+ClaudeDebug.swift index 5a2c64097..f836953fa 100644 --- a/Sources/CodexBar/UsageStore+ClaudeDebug.swift +++ b/Sources/CodexBar/UsageStore+ClaudeDebug.swift @@ -106,6 +106,12 @@ extension UsageStore { case .auto: lines.append("Auto source selected.") return lines.joined(separator: "\n") + case .api: + let hasAdminKey = ProviderTokenResolver.claudeAdminAPIToken( + environment: configuration.environment) != nil + lines.append("Admin API source selected.") + lines.append("hasAdminAPIKey=\(hasAdminKey)") + return lines.joined(separator: "\n") case .web: do { let web: ClaudeWebAPIFetcher.WebUsageData = diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index cc26cd0bf..c8443008b 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -3,22 +3,15 @@ import Foundation @MainActor extension UsageStore { - func supportsWeeklyPace(for provider: UsageProvider) -> Bool { - switch provider { - case .codex, .claude, .opencode, .abacus: - true - default: - false - } - } - private static let minimumPaceExpectedPercent: Double = 3 private static let backfillMaxTimestampMismatch: TimeInterval = 5 * 60 func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> UsagePace? { - guard self.supportsWeeklyPace(for: provider) else { return nil } guard window.remainingPercent > 0 else { return nil } let resolved: UsagePace? + // Codex can refine pace with historical samples because its dashboard exposes enough weekly history to build + // an account-scoped usage curve. Other providers should not need a hard-coded allowlist: if their RateWindow + // includes a reset time and window duration, the generic linear pace calculation is already defensible. if provider == .codex, self.settings.historicalTrackingEnabled { let codexAccountKey = self.codexOwnershipContext().canonicalKey if self.codexHistoricalDatasetAccountKey == codexAccountKey, @@ -32,6 +25,10 @@ extension UsageStore { resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) } } else { + // Generic providers must carry an explicit window duration. Using the 10080-minute fallback for + // windows without windowMinutes would fabricate a weekly pace for non-weekly windows + // (e.g. Factory monthly with only resetsAt). + guard window.windowMinutes != nil else { return nil } resolved = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) } @@ -98,7 +95,9 @@ extension UsageStore { { guard self.settings.historicalTrackingEnabled else { return } guard authorityDecision.allowedEffects.contains(.historicalBackfill) else { return } - guard !dashboard.usageBreakdown.isEmpty else { return } + let usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard.usageBreakdown) + guard !usageBreakdown.isEmpty else { return } let codexSnapshot = self.snapshots[.codex] let ownership = self.codexOwnershipContext(preferredEmail: attachedAccountEmail) @@ -128,7 +127,6 @@ extension UsageStore { } let historyStore = self.historicalUsageHistoryStore - let usageBreakdown = dashboard.usageBreakdown Task.detached(priority: .utility) { [weak self] in _ = await historyStore.backfillCodexWeeklyFromUsageBreakdown( usageBreakdown, diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 53b6e2016..c02e06c52 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -29,7 +29,7 @@ extension UsageStore { } private static let openAIWebRefreshMultiplier: TimeInterval = 5 - private static let openAIWebPrimaryFetchTimeout: TimeInterval = 15 + private static let openAIWebPrimaryFetchTimeout: TimeInterval = 25 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 private static let openAIWebPostImportFetchTimeout: TimeInterval = 25 @@ -285,6 +285,7 @@ extension UsageStore { self.lastSourceLabels.removeValue(forKey: .codex) self.lastFetchAttempts.removeValue(forKey: .codex) self.accountSnapshots.removeValue(forKey: .codex) + self.codexAccountSnapshots = [] self.failureGates[.codex]?.reset() self.lastKnownSessionRemaining.removeValue(forKey: .codex) self.lastKnownSessionWindowSource.removeValue(forKey: .codex) @@ -494,6 +495,13 @@ extension UsageStore { latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch { + if Self.isOpenAIDashboardTimeout(error) { + await self.retryOpenAIDashboardAfterTimeout( + context: context, + latestCookieImportStatus: &latestCookieImportStatus, + logger: log) + return + } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: context.targetEmail, @@ -506,6 +514,56 @@ extension UsageStore { } } + private func retryOpenAIDashboardAfterTimeout( + context: OpenAIDashboardRefreshContext, + latestCookieImportStatus: inout String?, + logger: @escaping (String) -> Void) async + { + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, + allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) + var effectiveEmail = targetEmail + let imported = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + preferCachedCookieHeader: true) + latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() + if await self.abortOpenAIDashboardRetryAfterImportFailure( + importedEmail: imported, + targetEmail: targetEmail, + expectedGuard: context.expectedGuard, + cookieImportStatus: latestCookieImportStatus, + refreshTaskToken: context.refreshTaskToken) + { + return + } + if let imported { + effectiveEmail = imported + } + do { + let dash = try await self.loadLatestOpenAIDashboard( + accountEmail: effectiveEmail, + logger: logger, + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + await self.applyOpenAIDashboard( + dash, + targetEmail: effectiveEmail, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + allowCodexUsageBackfill: context.allowCodexUsageBackfill) + } catch { + let message = self.preferredOpenAIDashboardFailureMessage( + error: error, + targetEmail: targetEmail, + cookieImportStatus: latestCookieImportStatus) + await self.applyOpenAIDashboardFailure( + message: message, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: targetEmail) + } + } + private func retryOpenAIDashboardAfterNoData( body: String, context: OpenAIDashboardRefreshContext, @@ -725,7 +783,9 @@ extension UsageStore { if status.localizedCaseInsensitiveContains("openai cookies are for") { return "\(status) Switch chatgpt.com account, then refresh OpenAI cookies." } - if status.localizedCaseInsensitiveContains("no signed-in openai web session found") { + if status.localizedCaseInsensitiveContains("no signed-in openai web session found") + || status.localizedCaseInsensitiveContains("no matching openai web session found") + { let targetLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let accountLabel = (targetLabel?.isEmpty == false) ? targetLabel! : "your OpenAI account" return "\(status) Sign in to chatgpt.com as \(accountLabel), then refresh OpenAI cookies." @@ -752,6 +812,11 @@ extension UsageStore { return error.localizedDescription } + private static func isOpenAIDashboardTimeout(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut + } + private func abortOpenAIDashboardRetryAfterImportFailure( importedEmail: String?, targetEmail: String?, @@ -855,7 +920,11 @@ extension UsageStore { return false } - func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { + func importOpenAIDashboardCookiesIfNeeded( + targetEmail: String?, + force: Bool, + preferCachedCookieHeader: Bool? = nil) async -> String? + { if await self.openAIWebCookieImportShouldFailClosed() { return nil } @@ -921,7 +990,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: !force, + preferCachedCookieHeader: preferCachedCookieHeader ?? !force, cacheScope: cacheScope, logger: log) case .off: @@ -1213,7 +1282,7 @@ extension UsageStore { let foundLabel: String = switch normalizedFound.count { case 0: - "another account" + "" case 1: normalizedFound[0] case 2: @@ -1223,6 +1292,12 @@ extension UsageStore { } let targetLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if normalizedFound.isEmpty { + guard let targetLabel, !targetLabel.isEmpty else { + return "No matching OpenAI web session found." + } + return "No matching OpenAI web session found for \(targetLabel)." + } guard let targetLabel, !targetLabel.isEmpty else { return "OpenAI cookies are for \(foundLabel)." } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 10eca18b0..a9b130e64 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -489,6 +489,33 @@ extension UsageStore { if provider == .codex { return CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) } + if provider == .claude { + let normalizedOrganization = identity.accountOrganization? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedLoginMethod = identity.loginMethod? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedPlan = ClaudePlan.fromCompatibilityLoginMethod(identity.loginMethod)?.rawValue + let organizationDiscriminator: String? = + if let normalizedOrganization, !normalizedOrganization.isEmpty { + "org:\(normalizedOrganization)" + } else { + nil + } + let planDiscriminator = normalizedPlan.map { "plan:\($0)" } + let loginMethodDiscriminator: String? = + if let normalizedLoginMethod, !normalizedLoginMethod.isEmpty { + "plan:\(normalizedLoginMethod)" + } else { + nil + } + let discriminator = organizationDiscriminator ?? planDiscriminator ?? loginMethodDiscriminator + guard let discriminator else { + return self.sha256Hex("claude:email:\(normalizedEmail)") + } + return self.sha256Hex("\(provider.rawValue):email:\(normalizedEmail):\(discriminator)") + } return self.sha256Hex("\(provider.rawValue):email:\(normalizedEmail)") } @@ -506,6 +533,15 @@ extension UsageStore { return nil } + private nonisolated static func legacyClaudePlanUtilizationEmailAccountKey(snapshot: UsageSnapshot) -> String? { + guard let identity = snapshot.identity(for: .claude) else { return nil } + let normalizedEmail = identity.accountEmail? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard let normalizedEmail, !normalizedEmail.isEmpty else { return nil } + return self.sha256Hex("claude:email:\(normalizedEmail)") + } + private func shouldDeferClaudePlanUtilizationHistory(provider: UsageProvider) -> Bool { provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) } @@ -600,16 +636,21 @@ extension UsageStore { if let snapshot, let identityAccountKey = Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) { + let resolvedIdentityAccountKey = self.materializeLegacyClaudePlanUtilizationHistoryIfNeeded( + into: identityAccountKey, + provider: provider, + snapshot: snapshot, + providerBuckets: &providerBuckets) if shouldUpdatePreferredAccountKey { - providerBuckets.preferredAccountKey = identityAccountKey + providerBuckets.preferredAccountKey = resolvedIdentityAccountKey } if shouldAdoptUnscopedHistory { self.adoptPlanUtilizationUnscopedHistoryIfNeeded( - into: identityAccountKey, + into: resolvedIdentityAccountKey, provider: provider, providerBuckets: &providerBuckets) } - return identityAccountKey + return resolvedIdentityAccountKey } if let stickyAccountKey = self.stickyPlanUtilizationAccountKey(providerBuckets: providerBuckets) { @@ -707,6 +748,34 @@ extension UsageStore { return canonicalKey } + private func materializeLegacyClaudePlanUtilizationHistoryIfNeeded( + into accountKey: String, + provider: UsageProvider, + snapshot: UsageSnapshot, + providerBuckets: inout PlanUtilizationHistoryBuckets) -> String + { + guard provider == .claude, + let legacyAccountKey = Self.legacyClaudePlanUtilizationEmailAccountKey(snapshot: snapshot), + legacyAccountKey != accountKey, + let legacyHistories = providerBuckets.accounts[legacyAccountKey], + !legacyHistories.isEmpty + else { + return accountKey + } + + let existingHistories = providerBuckets.accounts[accountKey] ?? [] + let mergedHistory = Self.mergedPlanUtilizationHistories(provider: provider, histories: [ + existingHistories, + legacyHistories, + ]) + providerBuckets.accounts.removeValue(forKey: legacyAccountKey) + providerBuckets.setHistories(mergedHistory, for: accountKey) + if providerBuckets.preferredAccountKey == legacyAccountKey { + providerBuckets.preferredAccountKey = accountKey + } + return accountKey + } + private func adoptPlanUtilizationUnscopedHistoryIfNeeded( into accountKey: String, provider: UsageProvider, @@ -963,6 +1032,10 @@ extension UsageStore { self.planUtilizationAccountKey(provider: provider, account: account) } + nonisolated static func _legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: UsageSnapshot) -> String? { + self.legacyClaudePlanUtilizationEmailAccountKey(snapshot: snapshot) + } + nonisolated static func _codexLegacyPlanUtilizationEmailHashKeyForTesting( normalizedEmail: String) -> String { diff --git a/Sources/CodexBar/UsageStore+ProviderStorage.swift b/Sources/CodexBar/UsageStore+ProviderStorage.swift new file mode 100644 index 000000000..3e5197eb5 --- /dev/null +++ b/Sources/CodexBar/UsageStore+ProviderStorage.swift @@ -0,0 +1,208 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + private struct StorageRefreshRequest { + let providers: [UsageProvider] + let candidatePathsByProvider: [UsageProvider: [String]] + let signature: String + } + + private static let automaticStorageRefreshInterval: TimeInterval = 5 * 60 + + var isStorageRefreshInFlight: Bool { + self.storageRefreshTask != nil + } + + func storageFootprint(for provider: UsageProvider) -> ProviderStorageFootprint? { + guard self.settings.providerStorageFootprintsEnabled else { return nil } + return self.providerStorageFootprints[provider] + } + + func storageFootprintText(for provider: UsageProvider) -> String? { + guard let footprint = self.storageFootprint(for: provider) else { return nil } + if footprint.hasLocalData { + return UsageFormatter.byteCountString(footprint.totalBytes) + } + return "No local data found" + } + + func refreshStorageFootprintsForOverview() { + self.scheduleStorageFootprintRefresh(for: self.enabledProvidersForDisplay()) + } + + func refreshStorageFootprintsForOverviewNow() async { + await self.refreshStorageFootprintsNow(for: self.enabledProvidersForDisplay()) + } + + func scheduleStorageFootprintRefreshForOverview(force: Bool = false) { + self.scheduleStorageFootprintRefresh(for: self.enabledProvidersForDisplay(), force: force) + } + + func refreshStorageFootprintsNow(for providers: [UsageProvider]) async { + guard self.settings.providerStorageFootprintsEnabled else { + self.clearStorageFootprints() + return + } + guard let request = self.makeStorageRefreshRequest(for: providers) else { + self.clearStorageFootprints() + return + } + + self.storageRefreshTask?.cancel() + self.storageRefreshGeneration &+= 1 + let generation = self.storageRefreshGeneration + self.storageRefreshInFlightSignature = request.signature + + let footprints = await Task.detached(priority: .utility) { + Self.scanStorageFootprints(candidatePathsByProvider: request.candidatePathsByProvider) + }.value + + guard generation == self.storageRefreshGeneration else { return } + self.applyStorageFootprints( + footprints, + providers: request.providers, + signature: request.signature, + updatedAt: Date()) + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + } + + func scheduleStorageFootprintRefresh(for providers: [UsageProvider], force: Bool = false) { + guard self.settings.providerStorageFootprintsEnabled else { + self.clearStorageFootprints() + return + } + guard let request = self.makeStorageRefreshRequest(for: providers) else { + self.clearStorageFootprints() + return + } + + let now = Date() + if self.storageRefreshTask != nil, + self.storageRefreshInFlightSignature == request.signature + { + return + } + if !force { + if self.lastStorageRefreshSignature == request.signature, + let lastStorageRefreshAt, + now.timeIntervalSince(lastStorageRefreshAt) < Self.automaticStorageRefreshInterval + { + return + } + } + + self.storageRefreshTask?.cancel() + self.storageRefreshGeneration &+= 1 + let generation = self.storageRefreshGeneration + self.storageRefreshInFlightSignature = request.signature + + self.storageRefreshTask = Task.detached(priority: .utility) { [weak self] in + let footprints = Self.scanStorageFootprints(candidatePathsByProvider: request.candidatePathsByProvider) + + await MainActor.run { [weak self] in + guard let self, + !Task.isCancelled, + generation == self.storageRefreshGeneration + else { return } + + self.applyStorageFootprints( + footprints, + providers: request.providers, + signature: request.signature, + updatedAt: Date()) + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + } + } + } + + private func clearStorageFootprints() { + self.storageRefreshTask?.cancel() + self.storageRefreshTask = nil + self.storageRefreshInFlightSignature = nil + self.lastStorageRefreshSignature = nil + self.lastStorageRefreshAt = nil + self.providerStorageFootprints.removeAll() + } + + private func applyStorageFootprints( + _ footprints: [UsageProvider: ProviderStorageFootprint], + providers: [UsageProvider], + signature: String, + updatedAt: Date) + { + let providerSet = Set(providers) + self.providerStorageFootprints = self.providerStorageFootprints.filter { !providerSet.contains($0.key) } + for provider in providers { + self.providerStorageFootprints[provider] = footprints[provider] + } + self.lastStorageRefreshSignature = signature + self.lastStorageRefreshAt = updatedAt + } + + private func makeStorageRefreshRequest(for providers: [UsageProvider]) -> StorageRefreshRequest? { + let uniqueProviders = Array(Set(providers)).sorted { $0.rawValue < $1.rawValue } + guard !uniqueProviders.isEmpty else { return nil } + + let environment = self.environmentBase + let managedAccounts = self.loadManagedCodexAccountsForStorage() + var candidatePathsByProvider: [UsageProvider: [String]] = [:] + + for provider in uniqueProviders { + let candidatePaths = ProviderStoragePathCatalog.candidatePaths( + for: provider, + environment: environment, + managedCodexAccounts: managedAccounts) + guard !candidatePaths.isEmpty else { continue } + candidatePathsByProvider[provider] = candidatePaths + } + + let providersWithPaths = uniqueProviders.filter { candidatePathsByProvider[$0] != nil } + guard !providersWithPaths.isEmpty else { return nil } + + let signature = providersWithPaths + .map { provider in + let paths = candidatePathsByProvider[provider]?.joined(separator: "\u{1f}") ?? "" + return "\(provider.rawValue)=\(paths)" + } + .joined(separator: "\u{1e}") + return StorageRefreshRequest( + providers: providersWithPaths, + candidatePathsByProvider: candidatePathsByProvider, + signature: signature) + } + + private func loadManagedCodexAccountsForStorage() -> [ManagedCodexAccount] { + if let managedCodexAccountsForStorageOverride { + return managedCodexAccountsForStorageOverride + } + return (try? FileManagedCodexAccountStore().loadAccounts().accounts) ?? [] + } + + private nonisolated static func scanStorageFootprints( + candidatePathsByProvider: [UsageProvider: [String]]) + -> [UsageProvider: ProviderStorageFootprint] + { + let scanner = ProviderStorageScanner() + var footprints: [UsageProvider: ProviderStorageFootprint] = [:] + var pathCache: [String: ProviderStorageFootprint] = [:] + + for provider in candidatePathsByProvider.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + if Task.isCancelled { return footprints } + guard let candidatePaths = candidatePathsByProvider[provider] else { continue } + let pathKey = candidatePaths.joined(separator: "\u{1f}") + if let cached = pathCache[pathKey] { + footprints[provider] = cached.replacingProvider(provider) + continue + } + let footprint = scanner.scan(provider: provider, candidatePaths: candidatePaths) + pathCache[pathKey] = footprint + footprints[provider] = footprint + } + + return footprints + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index c292f210a..b62f4a90e 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -21,10 +21,17 @@ extension UsageStore { 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() @@ -32,6 +39,7 @@ extension UsageStore { 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) } return @@ -40,6 +48,22 @@ extension UsageStore { self.refreshingProviders.insert(provider) defer { self.refreshingProviders.remove(provider) } + if provider == .codex, self.shouldFetchAllCodexVisibleAccounts() { + await self.refreshCodexVisibleAccountsForMenu() + return + } else if provider == .codex { + self.codexAccountSnapshots = [] + } + + if provider == .kilo, self.shouldFanOutKiloScopes() { + await self.refreshKiloScopes() + // Continue to also fetch the personal snapshot through the regular path + // so the existing single-card render keeps working when only personal is shown. + // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. + } else if provider == .kilo { + await MainActor.run { self.kiloScopeSnapshots = [] } + } + let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) @@ -50,6 +74,9 @@ extension UsageStore { } } + let claudeAuthStateBeforeFetch = provider == .claude + ? await Self.captureClaudeRefreshAuthState(invalidateCredentialsFile: true) + : nil let fetchContext = spec.makeFetchContext() let descriptor = spec.descriptor // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. @@ -62,22 +89,20 @@ extension UsageStore { } return await group.next()! } - if provider == .claude, - ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() - { - await MainActor.run { - self.snapshots.removeValue(forKey: .claude) - self.errors[.claude] = nil - self.lastSourceLabels.removeValue(forKey: .claude) - self.lastFetchAttempts.removeValue(forKey: .claude) - self.accountSnapshots.removeValue(forKey: .claude) - self.tokenSnapshots.removeValue(forKey: .claude) - self.tokenErrors[.claude] = nil - self.failureGates[.claude]?.reset() - self.tokenFailureGates[.claude]?.reset() - self.lastTokenFetchAt.removeValue(forKey: .claude) - } - } + let claudeAuthFingerprintAfterFetch = provider == .claude + ? await Self.captureClaudeAuthFingerprintToken() + : nil + let claudeAuthChangedDuringFetch = Self.claudeAuthChangedDuringFetch( + provider: provider, + beforeFetch: claudeAuthStateBeforeFetch, + afterFetchFingerprintToken: claudeAuthFingerprintAfterFetch) + await Self.invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: claudeAuthChangedDuringFetch) + let claudeCredentialsChanged = Self.claudeCredentialsChanged( + beforeFetch: claudeAuthStateBeforeFetch, + changedDuringFetch: claudeAuthChangedDuringFetch) + let shouldConsumeClaudeKeychainFingerprint = Self.shouldConsumeClaudeKeychainFingerprintChange( + beforeFetch: claudeAuthStateBeforeFetch, + changedDuringFetch: claudeAuthChangedDuringFetch) await MainActor.run { self.lastFetchAttempts[provider] = outcome.attempts } @@ -91,9 +116,15 @@ extension UsageStore { { return } - await MainActor.run { - self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) - self.snapshots[provider] = scoped + let backfilled = await MainActor.run { + if claudeCredentialsChanged { + self.clearClaudeCredentialDerivedStateForCredentialSwapNow() + } + let backfilled = scoped.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) + self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) + self.lastKnownResetSnapshots[provider] = backfilled + self.snapshots[provider] = backfilled self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() @@ -101,17 +132,21 @@ extension UsageStore { self.rememberLiveSystemCodexEmailIfNeeded(scoped.accountEmail(for: .codex)) self.seedCodexAccountScopedRefreshGuard(accountEmail: scoped.accountEmail(for: .codex)) } + return backfilled + } + if shouldConsumeClaudeKeychainFingerprint { + _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() } await self.recordPlanUtilizationHistorySample( provider: provider, - snapshot: scoped) + snapshot: backfilled) if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) runtime.providerDidRefresh(context: context, provider: provider) } if provider == .codex { - self.recordCodexHistoricalSampleIfNeeded(snapshot: scoped) + self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) } case let .failure(error): if provider == .codex, @@ -120,23 +155,183 @@ extension UsageStore { { return } - await MainActor.run { - let hadPriorData = self.snapshots[provider] != nil - let shouldSurface = - self.failureGates[provider]? - .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true - if shouldSurface { - self.errors[provider] = error.localizedDescription + if claudeCredentialsChanged { + await self.clearClaudeCredentialDerivedStateForCredentialSwap() + } + if shouldConsumeClaudeKeychainFingerprint { + _ = await Self.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + } + await self.handleProviderFetchFailure(provider: provider, error: error) + } + } + + private struct ClaudeRefreshAuthState { + let fingerprintToken: String + let credentialsFileChanged: Bool + let keychainFingerprintChanged: Bool + } + + private nonisolated static func claudeCredentialsChanged( + beforeFetch: ClaudeRefreshAuthState?, + changedDuringFetch: Bool) -> Bool + { + beforeFetch?.credentialsFileChanged == true || + beforeFetch?.keychainFingerprintChanged == true || + changedDuringFetch + } + + private nonisolated static func shouldConsumeClaudeKeychainFingerprintChange( + beforeFetch: ClaudeRefreshAuthState?, + changedDuringFetch: Bool) -> Bool + { + beforeFetch?.keychainFingerprintChanged == true || changedDuringFetch + } + + private nonisolated static func claudeAuthChangedDuringFetch( + provider: UsageProvider, + beforeFetch: ClaudeRefreshAuthState?, + afterFetchFingerprintToken: String?) -> Bool + { + provider == .claude && afterFetchFingerprintToken != beforeFetch?.fingerprintToken + } + + private nonisolated static func captureClaudeRefreshAuthState( + invalidateCredentialsFile: Bool) async -> ClaudeRefreshAuthState + { + await withTaskGroup(of: ClaudeRefreshAuthState.self, returning: ClaudeRefreshAuthState.self) { group in + group.addTask { + let fingerprintToken = ClaudeOAuthCredentialsStore.authFingerprintToken() + let credentialsFileChanged = invalidateCredentialsFile + ? ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + : false + let keychainFingerprintChanged = ClaudeOAuthCredentialsStore + .claudeKeychainFingerprintChangedWithoutConsuming() + return ClaudeRefreshAuthState( + fingerprintToken: fingerprintToken, + credentialsFileChanged: credentialsFileChanged, + keychainFingerprintChanged: keychainFingerprintChanged) + } + return await group.next()! + } + } + + private nonisolated static func captureClaudeAuthFingerprintToken() async -> String { + await withTaskGroup(of: String.self, returning: String.self) { group in + group.addTask { + ClaudeOAuthCredentialsStore.authFingerprintToken() + } + return await group.next()! + } + } + + private nonisolated static func invalidateClaudeCredentialsFileCacheIfChanged() async -> Bool { + await withTaskGroup(of: Bool.self, returning: Bool.self) { group in + group.addTask { + ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + } + return await group.next()! + } + } + + private nonisolated static func invalidateClaudeCredentialsFileCacheIfNeeded(changedDuringFetch: Bool) async { + guard changedDuringFetch else { return } + _ = await self.invalidateClaudeCredentialsFileCacheIfChanged() + } + + private nonisolated static func consumeClaudeKeychainFingerprintChangeWithoutPrompt() async -> Bool { + await withTaskGroup(of: Bool.self, returning: Bool.self) { group in + group.addTask { + ClaudeOAuthCredentialsStore.consumeClaudeKeychainFingerprintChangeWithoutPrompt() + } + return await group.next()! + } + } + + private func clearClaudeCredentialDerivedStateForCredentialSwap() async { + await MainActor.run { + self.clearClaudeCredentialDerivedStateForCredentialSwapNow() + } + } + + private func clearClaudeCredentialDerivedStateForCredentialSwapNow() { + self.snapshots.removeValue(forKey: .claude) + self.lastKnownResetSnapshots.removeValue(forKey: .claude) + self.errors[.claude] = nil + self.lastSourceLabels.removeValue(forKey: .claude) + self.accountSnapshots.removeValue(forKey: .claude) + self.tokenSnapshots.removeValue(forKey: .claude) + self.tokenErrors[.claude] = nil + self.failureGates[.claude]?.reset() + self.tokenFailureGates[.claude]?.reset() + self.lastKnownSessionRemaining.removeValue(forKey: .claude) + self.lastKnownSessionWindowSource.removeValue(forKey: .claude) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != .claude } + self.lastTokenFetchAt.removeValue(forKey: .claude) + } + + private func handleProviderFetchFailure(provider: UsageProvider, error: Error) async { + await MainActor.run { + let hadPriorData = self.snapshots[provider] != nil + let preservesPriorData = Self.shouldPreservePriorSnapshot( + after: error, + hadPriorData: hadPriorData) + let shouldSurface = + self.failureGates[provider]? + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + if provider == .claude, + preservesPriorData, + Self.isClaudeUsageProbeTimeout(error) + { + self.errors[provider] = nil + return + } + if preservesPriorData, !shouldSurface { + self.errors[provider] = nil + return + } + if shouldSurface { + self.errors[provider] = error.localizedDescription + if !preservesPriorData { self.snapshots.removeValue(forKey: provider) - } else { - self.errors[provider] = nil } + } else { + self.errors[provider] = nil } - if let runtime = self.providerRuntimes[provider] { - let context = ProviderRuntimeContext( - provider: provider, settings: self.settings, store: self) - runtime.providerDidFail(context: context, provider: provider, error: error) + } + if let runtime = self.providerRuntimes[provider] { + let context = ProviderRuntimeContext( + provider: provider, settings: self.settings, store: self) + runtime.providerDidFail(context: context, provider: provider, error: error) + } + } + + private static func shouldPreservePriorSnapshot(after error: Error, hadPriorData: Bool) -> Bool { + guard hadPriorData else { return false } + if error is CancellationError { return true } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorCancelled, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet: + return true + default: + break } } + + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || + message.contains("timeout") || + message.contains("cancelled") || + message.contains("network connection was lost") || + message.contains("not connected to the internet") + } + + private static func isClaudeUsageProbeTimeout(_ error: Error) -> Bool { + if case ClaudeStatusProbeError.timedOut = error { return true } + return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } } diff --git a/Sources/CodexBar/UsageStore+Status.swift b/Sources/CodexBar/UsageStore+Status.swift index abf7aee47..4d1b85e4d 100644 --- a/Sources/CodexBar/UsageStore+Status.swift +++ b/Sources/CodexBar/UsageStore+Status.swift @@ -1,12 +1,17 @@ +import CodexBarCore import Foundation extension UsageStore { - static func fetchStatus(from baseURL: URL) async throws -> ProviderStatus { + static func fetchStatus( + from baseURL: URL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + async throws -> ProviderStatus + { let apiURL = baseURL.appendingPathComponent("api/v2/status.json") var request = URLRequest(url: apiURL) request.timeoutInterval = 10 - let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) + let (data, _) = try await transport.data(for: request) struct Response: Decodable { struct Status: Decodable { @@ -46,13 +51,17 @@ extension UsageStore { updatedAt: response.page?.updatedAt) } - static func fetchWorkspaceStatus(productID: String) async throws -> ProviderStatus { + static func fetchWorkspaceStatus( + productID: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + async throws -> ProviderStatus + { guard let url = URL(string: "https://www.google.com/appsstatus/dashboard/incidents.json") else { throw URLError(.badURL) } var request = URLRequest(url: url) request.timeoutInterval = 10 - let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) + let (data, _) = try await transport.data(for: request) return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7a00c1236..8133ea813 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -17,7 +17,25 @@ struct TokenAccountUsageSnapshot: Identifiable { } } +struct CodexAccountUsageSnapshot: Identifiable { + let id: String + let account: CodexVisibleAccount + let snapshot: UsageSnapshot? + let error: String? + let sourceLabel: String? + + init(account: CodexVisibleAccount, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) { + self.id = account.id + self.account = account + self.snapshot = snapshot + self.error = error + self.sourceLabel = sourceLabel + } +} + extension UsageStore { + static let tokenAccountMenuSnapshotLimit = 6 + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) @@ -25,23 +43,122 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } - return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 + return self.settings.multiAccountMenuLayout == .stacked && accounts.count > 1 + } + + func shouldFetchAllCodexVisibleAccounts() -> Bool { + self.settings.multiAccountMenuLayout == .stacked && + self.settings.codexVisibleAccountProjection.visibleAccounts.count > 1 + } + + func refreshCodexVisibleAccountsForMenu() async { + let projection = self.settings.codexVisibleAccountProjection + let accounts = self.limitedCodexVisibleAccounts( + projection.visibleAccounts, + snapshots: self.codexAccountSnapshots, + activeVisibleAccountID: projection.activeVisibleAccountID) + guard accounts.count > 1 else { + self.codexAccountSnapshots = [] + return + } + + let originalVisibleAccountID = projection.activeVisibleAccountID + let originalSelectionSource = originalVisibleAccountID.flatMap { + projection.source(forVisibleAccountID: $0) + } + let priorByAccountID = Dictionary(uniqueKeysWithValues: self.codexAccountSnapshots.map { ($0.id, $0) }) + var snapshots: [CodexAccountUsageSnapshot] = [] + var selectedOutcome: ProviderFetchOutcome? + var selectedSnapshot: UsageSnapshot? + var selectedSourceLabel: String? + var sawAnyNonCancellationOutcome = false + + for account in accounts { + let outcome = await self.fetchOutcome( + provider: .codex, + override: nil, + codexActiveSourceOverride: account.selectionSource) + let isCancellation = Self.outcomeIsCancellation(outcome) + if !isCancellation { + sawAnyNonCancellationOutcome = true + } + let resolved = self.resolveCodexAccountOutcome( + outcome, + account: account, + priorSnapshot: priorByAccountID[account.id]) + if let snapshot = resolved.snapshot { + snapshots.append(snapshot) + } + if account.id == originalVisibleAccountID { + selectedOutcome = outcome + selectedSnapshot = resolved.usage + selectedSourceLabel = resolved.sourceLabel + } + } + + let shouldPreservePriorState = !sawAnyNonCancellationOutcome && + snapshots.allSatisfy { $0.snapshot == nil } + if !shouldPreservePriorState { + self.codexAccountSnapshots = snapshots + self.codexAccountUsageSnapshotStore?.store(snapshots) + } + + let selectionStillMatches = self.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource) + if let selectedOutcome, selectionStillMatches { + await self.applySelectedCodexVisibleAccountOutcome( + selectedOutcome, + snapshot: selectedSnapshot, + sourceLabel: selectedSourceLabel) + } + } + + func codexVisibleSelectionStillMatches( + originalVisibleAccountID: String?, + originalSelectionSource: CodexActiveSource?) -> Bool + { + let currentProjection = self.settings.codexVisibleAccountProjection + let currentSelectionSource = originalVisibleAccountID.flatMap { + currentProjection.source(forVisibleAccountID: $0) + } + return currentProjection.activeVisibleAccountID == originalVisibleAccountID && + currentSelectionSource == originalSelectionSource } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first + + // Capture the prior per-account snapshot state so we can preserve last-good + // data when an in-flight refresh is cancelled (e.g. menu tab switches). Without + // this, cancellation produces empty/error snapshots and the menu briefly shows + // misleading cards for accounts that previously had valid data. + let priorSnapshots = await MainActor.run { self.accountSnapshots[provider] ?? [] } + let priorByAccountID = Dictionary(uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) + var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? + var sawAnyNonCancellationOutcome = false for account in limitedAccounts { let override = TokenAccountOverride(provider: provider, account: account) let outcome = await self.fetchOutcome(provider: provider, override: override) - let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) - snapshots.append(resolved.snapshot) + let isCancellation = Self.outcomeIsCancellation(outcome) + if !isCancellation { + sawAnyNonCancellationOutcome = true + } + let resolved = self.resolveAccountOutcome( + outcome, + provider: provider, + account: account, + priorSnapshot: priorByAccountID[account.id]) + if let snapshot = resolved.snapshot { + snapshots.append(snapshot) + } if let usage = resolved.usage { historySamples.append((account: account, snapshot: usage)) } @@ -51,8 +168,15 @@ extension UsageStore { } } - await MainActor.run { - self.accountSnapshots[provider] = snapshots + // If every fetch was cancelled (e.g. the user closed/reopened the menu mid-flight) + // and we have no usable snapshots, leave the prior per-account state alone. + // Wiping it would produce a menu of useless "cancelled" placeholders. + let shouldPreservePriorState = !sawAnyNonCancellationOutcome && + snapshots.allSatisfy { $0.snapshot == nil } + if !shouldPreservePriorState { + await MainActor.run { + self.accountSnapshots[provider] = snapshots + } } if let selectedOutcome { @@ -69,11 +193,36 @@ extension UsageStore { selectedAccount: effectiveSelected) } + private static func outcomeIsCancellation(_ outcome: ProviderFetchOutcome) -> Bool { + if case let .failure(error) = outcome.result, error is CancellationError { + return true + } + if case let .failure(error) = outcome.result { + return self.errorIsCancellation(error) + } + return false + } + + private static func errorIsCancellation(_ error: any Error) -> Bool { + if error is CancellationError { + return true + } + if let urlError = error as? URLError, urlError.code == .cancelled { + return true + } + let message = error.localizedDescription + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return message == "cancelled" || + message.contains("cancellationerror") || + message.contains("cancelled") + } + func limitedTokenAccounts( _ accounts: [ProviderTokenAccount], selected: ProviderTokenAccount?) -> [ProviderTokenAccount] { - let limit = 6 + let limit = Self.tokenAccountMenuSnapshotLimit if accounts.count <= limit { return accounts } var limited = Array(accounts.prefix(limit)) if let selected, !limited.contains(where: { $0.id == selected.id }) { @@ -83,32 +232,68 @@ extension UsageStore { return limited } + func limitedCodexVisibleAccounts( + _ accounts: [CodexVisibleAccount], + snapshots: [CodexAccountUsageSnapshot] = [], + activeVisibleAccountID: String?) -> [CodexVisibleAccount] + { + let accounts = CodexAccountPresentationOrdering.orderedAccounts( + accounts, + snapshots: snapshots, + activeVisibleAccountID: activeVisibleAccountID) + let limit = Self.tokenAccountMenuSnapshotLimit + if accounts.count <= limit { return accounts } + var limited = Array(accounts.prefix(limit)) + if let activeVisibleAccountID, + let active = accounts.first(where: { $0.id == activeVisibleAccountID }), + !limited.contains(where: { $0.id == activeVisibleAccountID }) + { + limited.removeLast() + limited.append(active) + } + return limited + } + func fetchOutcome( provider: UsageProvider, - override: TokenAccountOverride?) async -> ProviderFetchOutcome + override: TokenAccountOverride?, + codexActiveSourceOverride: CodexActiveSource? = nil) async -> ProviderFetchOutcome { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) - let context = self.makeFetchContext(provider: provider, override: override) + let context = self.makeFetchContext( + provider: provider, + override: override, + codexActiveSourceOverride: codexActiveSourceOverride) return await descriptor.fetchOutcome(context: context) } func makeFetchContext( provider: UsageProvider, - override: TokenAccountOverride?) -> ProviderFetchContext + override: TokenAccountOverride?, + codexActiveSourceOverride: CodexActiveSource? = nil) -> ProviderFetchContext { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: provider, + settings: self.settings, + override: override) let sourceMode = self.sourceMode(for: provider) - let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override) + let snapshot = ProviderRegistry.makeSettingsSnapshot( + settings: self.settings, + tokenOverride: override, + codexActiveSourceOverride: codexActiveSourceOverride) let env = ProviderRegistry.makeEnvironment( base: self.environmentBase, provider: provider, settings: self.settings, - tokenOverride: override) + tokenOverride: override, + codexActiveSourceOverride: codexActiveSourceOverride) let fetcher = ProviderRegistry.makeFetcher(base: self.codexFetcher, provider: provider, env: env) let verbose = self.settings.isVerboseLoggingEnabled return ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, + includeOptionalUsage: self.settings.showOptionalCreditsAndExtraUsage, webTimeout: 60, webDebugDumpHTML: false, verbose: verbose, @@ -116,7 +301,16 @@ extension UsageStore { settings: snapshot, fetcher: fetcher, claudeFetcher: self.claudeFetcher, - browserDetection: self.browserDetection) + browserDetection: self.browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: { [weak settings = self.settings] provider, accountID, token in + await MainActor.run { + settings?.updateTokenAccount( + provider: provider, + accountID: accountID, + token: token) + } + }) } func sourceMode(for provider: UsageProvider) -> ProviderSourceMode { @@ -126,8 +320,27 @@ extension UsageStore { } private struct ResolvedAccountOutcome { - let snapshot: TokenAccountUsageSnapshot + let snapshot: TokenAccountUsageSnapshot? + let usage: UsageSnapshot? + } + + private struct ResolvedCodexAccountOutcome { + let snapshot: CodexAccountUsageSnapshot? let usage: UsageSnapshot? + let sourceLabel: String? + } + + func tokenAccountErrorMessage(_ error: any Error) -> String? { + guard !Self.errorIsCancellation(error) else { return nil } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } + + /// Per-account snapshot error text. Cancellation is handled before this path so + /// transient menu refresh cancellation does not render as a user-facing error. + func tokenAccountSnapshotErrorMessage(_ error: any Error) -> String { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? "Refresh failed" : message } func recordFetchedTokenAccountPlanUtilizationHistory( @@ -148,7 +361,8 @@ extension UsageStore { private func resolveAccountOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, - account: ProviderTokenAccount) -> ResolvedAccountOutcome + account: ProviderTokenAccount, + priorSnapshot: TokenAccountUsageSnapshot? = nil) -> ResolvedAccountOutcome { switch outcome.result { case let .success(result): @@ -161,15 +375,105 @@ extension UsageStore { sourceLabel: result.sourceLabel) return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) case let .failure(error): + // Preserve the last-good snapshot when the refresh was cancelled (e.g. the + // user switched menu tabs mid-flight). Without this the per-account list + // would briefly render error chips for accounts that already had data. + if Self.errorIsCancellation(error) { + if let priorSnapshot, priorSnapshot.snapshot != nil { + return ResolvedAccountOutcome(snapshot: priorSnapshot, usage: priorSnapshot.snapshot) + } + // No usable prior data: skip this row entirely. The caller will + // either preserve the existing per-account state or fall back to + // the single live card. Rendering a "cancelled" placeholder here + // produces visually duplicate cards with no useful data. + return ResolvedAccountOutcome(snapshot: nil, usage: nil) + } let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, - error: error.localizedDescription, + error: self.tokenAccountSnapshotErrorMessage(error), sourceLabel: nil) return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) } } + private func resolveCodexAccountOutcome( + _ outcome: ProviderFetchOutcome, + account: CodexVisibleAccount, + priorSnapshot: CodexAccountUsageSnapshot? = nil) -> ResolvedCodexAccountOutcome + { + switch outcome.result { + case let .success(result): + let scoped = result.usage.scoped(to: .codex) + let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account) + let snapshot = CodexAccountUsageSnapshot( + account: account, + snapshot: labeled, + error: nil, + sourceLabel: result.sourceLabel) + return ResolvedCodexAccountOutcome( + snapshot: snapshot, + usage: labeled, + sourceLabel: result.sourceLabel) + case let .failure(error): + if Self.errorIsCancellation(error) { + if let priorSnapshot, priorSnapshot.snapshot != nil { + return ResolvedCodexAccountOutcome( + snapshot: priorSnapshot, + usage: priorSnapshot.snapshot, + sourceLabel: priorSnapshot.sourceLabel) + } + return ResolvedCodexAccountOutcome(snapshot: nil, usage: nil, sourceLabel: nil) + } + let snapshot = CodexAccountUsageSnapshot( + account: account, + snapshot: nil, + error: self.tokenAccountSnapshotErrorMessage(error), + sourceLabel: nil) + return ResolvedCodexAccountOutcome(snapshot: snapshot, usage: nil, sourceLabel: nil) + } + } + + func applySelectedCodexVisibleAccountOutcome( + _ outcome: ProviderFetchOutcome, + snapshot: UsageSnapshot?, + sourceLabel: String?) async + { + self.lastFetchAttempts[.codex] = outcome.attempts + switch outcome.result { + case .success: + guard let snapshot else { return } + let backfilled = snapshot.backfillingResetTimes(from: self.lastKnownResetSnapshots[.codex]) + self.handleSessionQuotaTransition(provider: .codex, snapshot: backfilled) + self.lastKnownResetSnapshots[.codex] = backfilled + self.snapshots[.codex] = backfilled + if let sourceLabel { + self.lastSourceLabels[.codex] = sourceLabel + } + self.errors[.codex] = nil + self.failureGates[.codex]?.recordSuccess() + self.rememberLiveSystemCodexEmailIfNeeded(backfilled.accountEmail(for: .codex)) + self.seedCodexAccountScopedRefreshGuard(accountEmail: backfilled.accountEmail(for: .codex)) + await self.recordPlanUtilizationHistorySample(provider: .codex, snapshot: backfilled) + self.recordCodexHistoricalSampleIfNeeded(snapshot: backfilled) + case let .failure(error): + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[.codex] = nil + return + } + let hadPriorData = self.snapshots[.codex] != nil + let shouldSurface = + self.failureGates[.codex]? + .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + if shouldSurface { + self.errors[.codex] = message + self.snapshots.removeValue(forKey: .codex) + } else { + self.errors[.codex] = nil + } + } + } + func applySelectedOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, @@ -187,24 +491,32 @@ extension UsageStore { } else { scoped } - await MainActor.run { - self.handleSessionQuotaTransition(provider: provider, snapshot: labeled) - self.snapshots[provider] = labeled + let backfilled = await MainActor.run { + let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) + self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) + self.lastKnownResetSnapshots[provider] = backfilled + self.snapshots[provider] = backfilled self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() + return backfilled } await self.recordPlanUtilizationHistorySample( provider: provider, - snapshot: labeled, + snapshot: backfilled, account: account) case let .failure(error): await MainActor.run { + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[provider] = nil + return + } let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { - self.errors[provider] = error.localizedDescription + self.errors[provider] = message self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil @@ -230,4 +542,17 @@ extension UsageStore { loginMethod: existing?.loginMethod) return snapshot.withIdentity(identity) } + + func applyCodexVisibleAccountLabel(_ snapshot: UsageSnapshot, account: CodexVisibleAccount) -> UsageSnapshot { + let existing = snapshot.identity(for: .codex) + let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedEmail = (email?.isEmpty ?? true) ? account.email : email + let loginMethod = existing?.loginMethod ?? account.workspaceLabel + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: resolvedEmail, + accountOrganization: existing?.accountOrganization, + loginMethod: loginMethod) + return snapshot.withIdentity(identity) + } } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index f00f14504..d9317b0df 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -18,6 +18,18 @@ extension UsageStore { self.tokenRefreshInFlight.contains(provider) } + func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) { + guard provider == .codex else { + return (nil, provider.rawValue) + } + let homePath = self.settings.activeManagedCodexRemoteHomePath? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let homePath, !homePath.isEmpty else { + return (nil, "codex:ambient") + } + return (homePath, "codex:managed:\(homePath)") + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3704feeda..3dad118a8 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -14,6 +14,8 @@ extension UsageStore { _ = self.lastSourceLabels _ = self.lastFetchAttempts _ = self.accountSnapshots + _ = self.codexAccountSnapshots + _ = self.kiloScopeSnapshots _ = self.tokenSnapshots _ = self.tokenErrors _ = self.tokenRefreshInFlight @@ -29,6 +31,7 @@ extension UsageStore { _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision + _ = self.providerStorageFootprints return 0 } @@ -52,6 +55,11 @@ extension UsageStore { _ = self.settings.refreshFrequency _ = self.settings.statusChecksEnabled _ = self.settings.sessionQuotaNotificationsEnabled + _ = self.settings.quotaWarningNotificationsEnabled + _ = self.settings.quotaWarningThresholds + _ = self.settings.quotaWarningThresholds(.session) + _ = self.settings.quotaWarningThresholds(.weekly) + _ = self.settings.quotaWarningSoundEnabled _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.randomBlinkEnabled @@ -59,17 +67,19 @@ extension UsageStore { for implementation in ProviderCatalog.all { implementation.observeSettings(self.settings) } - _ = self.settings.showAllTokenAccountsInMenu + _ = self.settings.multiAccountMenuLayout _ = self.settings.tokenAccountsByProvider _ = self.settings.mergeIcons _ = self.settings.selectedMenuProvider _ = self.settings.debugLoadingPattern _ = self.settings.debugKeepCLISessionsAlive _ = self.settings.historicalTrackingEnabled + _ = self.settings.providerStorageFootprintsEnabled } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.observeSettingsChanges() + self.invalidateProviderAvailabilityCache() self.probeLogs = [:] guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } self.startTimer() @@ -89,6 +99,16 @@ extension UsageStore { @MainActor @Observable final class UsageStore { + private struct ProviderAvailabilityCacheEntry { + let available: Bool + let configRevision: Int + let expiresAt: Date + + func isValid(now: Date, configRevision: Int) -> Bool { + self.configRevision == configRevision && self.expiresAt > now + } + } + enum CodexCreditsSource { case none case api @@ -124,6 +144,8 @@ final class UsageStore { var lastSourceLabels: [UsageProvider: String] = [:] var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] + var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] + var kiloScopeSnapshots: [KiloScopeSnapshot] = [] var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] @@ -142,6 +164,7 @@ final class UsageStore { var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 + var providerStorageFootprints: [UsageProvider: ProviderStorageFootprint] = [:] @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none @@ -192,21 +215,33 @@ final class UsageStore { @ObservationIgnored var providerSpecs: [UsageProvider: ProviderSpec] = [:] @ObservationIgnored let providerMetadata: [UsageProvider: ProviderMetadata] @ObservationIgnored var providerRuntimes: [UsageProvider: any ProviderRuntime] = [:] + @ObservationIgnored private var providerAvailabilityCache: [UsageProvider: ProviderAvailabilityCacheEntry] = [:] @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? + @ObservationIgnored var storageRefreshTask: Task? + @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 + @ObservationIgnored var storageRefreshInFlightSignature: String? + @ObservationIgnored var lastStorageRefreshSignature: String? + @ObservationIgnored var lastStorageRefreshAt: Date? + @ObservationIgnored var managedCodexAccountsForStorageOverride: [ManagedCodexAccount]? @ObservationIgnored private var pathDebugRefreshTask: Task? @ObservationIgnored var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore + @ObservationIgnored let codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? + @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] + @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false + @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 @@ -221,6 +256,7 @@ final class UsageStore { registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), + codexAccountUsageSnapshotStore: (any CodexAccountUsageSnapshotStoring)? = nil, sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic, environmentBase: [String: String] = ProcessInfo.processInfo.environment) @@ -236,6 +272,8 @@ final class UsageStore { self.planUtilizationHistoryStore = planUtilizationHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) + self.codexAccountUsageSnapshotStore = codexAccountUsageSnapshotStore ?? + (self.startupBehavior.automaticallyStartsBackgroundWork ? FileCodexAccountUsageSnapshotStore() : nil) self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator( store: planUtilizationHistoryStore) self.providerMetadata = registry.metadata @@ -258,6 +296,10 @@ final class UsageStore { }) self.planUtilizationHistory = planUtilizationHistoryStore.load() self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults) + if let codexAccountUsageSnapshotStore = self.codexAccountUsageSnapshotStore { + self.codexAccountSnapshots = codexAccountUsageSnapshotStore.load( + for: settings.codexVisibleAccountProjection.visibleAccounts) + } self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( @@ -342,7 +384,8 @@ final class UsageStore { func enabledProviders() -> [UsageProvider] { // Use cached enablement to avoid repeated UserDefaults lookups in animation ticks. let enabled = self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata) - return enabled.filter { self.isProviderAvailable($0) } + let now = Date() + return enabled.filter { self.isProviderAvailable($0, now: now) } } /// Enabled providers without availability filtering. Used for display (switcher, merge-icons). @@ -421,6 +464,19 @@ final class UsageStore { } func isProviderAvailable(_ provider: UsageProvider) -> Bool { + self.isProviderAvailable(provider, now: Date()) + } + + private func isProviderAvailable(_ provider: UsageProvider, now: Date) -> Bool { + guard provider != .codex else { return true } + + let configRevision = self.settings.configRevision + if let cached = self.providerAvailabilityCache[provider], + cached.isValid(now: now, configRevision: configRevision) + { + return cached.available + } + // Availability should mirror the effective fetch environment, including token-account overrides. // Otherwise providers (notably token-account-backed API providers) can fetch successfully but be // hidden from the menu because their credentials are not in ProcessInfo's environment. @@ -433,9 +489,18 @@ final class UsageStore { provider: provider, settings: self.settings, environment: environment) - return ProviderCatalog.implementation(for: provider)? + let available = ProviderCatalog.implementation(for: provider)? .isAvailable(context: context) ?? true + self.providerAvailabilityCache[provider] = ProviderAvailabilityCacheEntry( + available: available, + configRevision: configRevision, + expiresAt: now.addingTimeInterval(self.providerAvailabilityCacheTTL)) + return available + } + + private func invalidateProviderAvailabilityCache() { + self.providerAvailabilityCache.removeAll(keepingCapacity: true) } func performRuntimeAction(_ action: ProviderRuntimeAction, for provider: UsageProvider) async { @@ -477,6 +542,7 @@ final class UsageStore { self.clearUnavailableProviderState( displayEnabledProviders: enabledProviderSet, availableProviders: availableRefreshProviders) + self.scheduleStorageFootprintRefresh(for: displayEnabledProviders) await withTaskGroup(of: Void.self) { group in for provider in refreshProviders { @@ -598,6 +664,7 @@ final class UsageStore { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() } @@ -606,11 +673,21 @@ final class UsageStore { case copilotSecondaryFallback } + struct QuotaWarningStateKey: Hashable { + let provider: UsageProvider + let window: QuotaWarningWindow + } + + struct QuotaWarningState { + var lastRemaining: Double? + var firedThresholds: Set = [] + } + private func sessionQuotaWindow( provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { - if let primary = snapshot.primary { + if let primary = snapshot.primary, Self.isSessionWindow(primary) { return (primary, .primary) } if provider == .copilot, let secondary = snapshot.secondary { @@ -619,6 +696,11 @@ final class UsageStore { return nil } + private static func isSessionWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { return true } + return minutes <= 6 * 60 + } + func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. @@ -696,6 +778,77 @@ final class UsageStore { self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } + func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { + guard self.settings.quotaWarningNotificationsEnabled else { return } + + let accountDisplayName = self.quotaWarningAccountDisplayName(provider: provider, snapshot: snapshot) + self.handleQuotaWarningTransition( + provider: provider, + window: .session, + rateWindow: snapshot.primary, + accountDisplayName: accountDisplayName) + self.handleQuotaWarningTransition( + provider: provider, + window: .weekly, + rateWindow: snapshot.secondary, + accountDisplayName: accountDisplayName) + } + + private func handleQuotaWarningTransition( + provider: UsageProvider, + window: QuotaWarningWindow, + rateWindow: RateWindow?, + accountDisplayName: String?) + { + let key = QuotaWarningStateKey(provider: provider, window: window) + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { + self.quotaWarningState.removeValue(forKey: key) + return + } + guard let rateWindow else { + self.quotaWarningState.removeValue(forKey: key) + return + } + + let thresholds = self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + let currentRemaining = rateWindow.remainingPercent + var state = self.quotaWarningState[key] ?? QuotaWarningState() + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: currentRemaining, + alreadyFired: state.firedThresholds) + state.firedThresholds.subtract(cleared) + + if let threshold = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: state.lastRemaining, + currentRemaining: currentRemaining, + thresholds: thresholds, + alreadyFired: state.firedThresholds) + { + state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: threshold, + thresholds: thresholds)) + self.sessionQuotaNotifier.postQuotaWarning( + event: QuotaWarningEvent( + window: window, + threshold: threshold, + currentRemaining: currentRemaining, + accountDisplayName: accountDisplayName), + provider: provider, + soundEnabled: self.settings.quotaWarningSoundEnabled) + } + + state.lastRemaining = currentRemaining + self.quotaWarningState[key] = state + } + + private func quotaWarningAccountDisplayName(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard !self.settings.hidePersonalInfo else { return nil } + let account = snapshot.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let account, !account.isEmpty else { return nil } + return account + } + private func refreshStatus(_ provider: UsageProvider) async { guard self.settings.statusChecksEnabled else { return } guard let meta = self.providerMetadata[provider] else { return } @@ -784,14 +937,16 @@ extension UsageStore { let ollamaCookieSource = self.settings.ollamaCookieSource let ollamaCookieHeader = self.settings.ollamaCookieHeader let processEnvironment = self.environmentBase - let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey - let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) - let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil - let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + let openAIDebugContext = self.openAIAPIKeyDebugContext(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: .openrouter, - config: self.settings.providerConfig(for: .openrouter)) + provider: .deepseek, + settings: self.settings, + tokenOverride: nil) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() @@ -803,17 +958,28 @@ extension UsageStore { .alibaba: "Alibaba Coding 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", + .deepgram: "Deepgram debug log not yet implemented", ] let buildText = { switch provider { case .codex: return await codexFetcher.debugRawRateLimits() + case .openai: + return Self.apiKeyDebugLine(openAIDebugContext) case .claude: guard let claudeDebugConfiguration else { return "Claude debug log configuration unavailable" @@ -864,25 +1030,24 @@ extension UsageStore { ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: - let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) - let hasAny = resolution != nil - let source: String = if resolution == nil { - "none" - } else if openRouterHasConfigToken, openRouterHasEnvToken { - "settings-config (overrides env)" - } else if openRouterHasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + 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, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity, .abacus, .mistral: + .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .abacus, .mistral, .codebuff, .crof, + .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, .grok, .deepgram: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } @@ -1003,6 +1168,90 @@ extension UsageStore { #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 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, @@ -1168,17 +1417,19 @@ extension UsageStore { self.tokenSnapshots.removeAll() self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() + self.lastTokenFetchScope.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() return nil } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } @@ -1187,6 +1438,7 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } @@ -1195,19 +1447,23 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() + let costScope = self.tokenCostScope(for: provider) if !force, let last = self.lastTokenFetchAt[provider], + self.lastTokenFetchScope[provider] == costScope.signature, now.timeIntervalSince(last) < self.tokenFetchTTL { return } self.lastTokenFetchAt[provider] = now + self.lastTokenFetchScope[provider] = costScope.signature self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } @@ -1219,6 +1475,13 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let environment = provider == .bedrock + ? ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: provider, + settings: self.settings, + tokenOverride: nil) + : self.environmentBase // CostUsageFetcher scans local Codex 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. @@ -1228,9 +1491,11 @@ extension UsageStore { group.addTask(priority: .utility) { try await fetcher.loadTokenSnapshot( provider: provider, + environment: environment, now: now, forceRefresh: force, - allowVertexClaudeFallback: !self.isEnabled(.claude)) + allowVertexClaudeFallback: !self.isEnabled(.claude), + codexHomePath: costScope.codexHomePath) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 522416898..3ac92fc59 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -18,12 +18,12 @@ enum ProviderStatusIndicator: String { var label: String { switch self { - case .none: "Operational" - case .minor: "Partial outage" - case .major: "Major outage" - case .critical: "Critical issue" - case .maintenance: "Maintenance" - case .unknown: "Status unknown" + case .none: L("status_operational") + case .minor: L("status_partial_outage") + case .major: L("status_major_outage") + case .critical: L("status_critical_issue") + case .maintenance: L("status_maintenance") + case .unknown: L("status_unknown") } } } diff --git a/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift new file mode 100644 index 000000000..47bf08f13 --- /dev/null +++ b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift @@ -0,0 +1,251 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct ZaiHourlyUsageChartMenuView: View { + private let modelUsage: ZaiModelUsageData + private let width: CGFloat + + @State private var selectedRange: RangeOption = .today + @State private var isExpanded = true + @State private var hoveredBarIndex: Int? + + private enum RangeOption: Int, CaseIterable { + case today = 0 + case last24h = 1 + } + + private let barHeight: CGFloat = 60 + private let barGap: CGFloat = 2 + private let maxLabelCount = 5 + + private let colorPalette: [Color] = [ + Color(red: 10 / 255, green: 132 / 255, blue: 1), + Color(red: 255 / 255, green: 159 / 255, blue: 10 / 255), + Color(red: 48 / 255, green: 209 / 255, blue: 88 / 255), + Color(red: 94 / 255, green: 92 / 255, blue: 230 / 255), + Color(red: 100 / 255, green: 210 / 255, blue: 255 / 255), + Color(red: 255 / 255, green: 55 / 255, blue: 95 / 255), + ] + + init(modelUsage: ZaiModelUsageData, width: CGFloat) { + self.modelUsage = modelUsage + self.width = width + } + + private var range: ZaiHourlyRange { + switch self.selectedRange { + case .today: .today(referenceDate: Date()) + case .last24h: .last24h + } + } + + private var bars: [ZaiHourlyBar] { + ZaiHourlyBars.from(modelData: self.modelUsage, range: self.range) + } + + private var modelNames: [String] { + self.modelUsage.modelNames + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + Button( + action: { withAnimation(.easeInOut(duration: 0.2)) { self.isExpanded.toggle() } }, + label: { + Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 8)) + .foregroundColor(.secondary) + .frame(width: 10) + }) + .buttonStyle(.plain) + + Text("Hourly Tokens") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + if self.isExpanded { + self.rangeToggle + } + } + + if self.isExpanded { + VStack(alignment: .leading, spacing: 4) { + if self.bars.isEmpty { + Text("No data") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 16) + } else { + GeometryReader { geometry in + let barWidth = max( + (geometry.size.width - self.barGap * CGFloat(max(self.bars.count - 1, 0))) + / CGFloat(self.bars.count), + 2) + HStack(alignment: .bottom, spacing: self.barGap) { + ForEach(Array(self.bars.enumerated()), id: \.offset) { index, bar in + VStack(spacing: 0) { + Spacer(minLength: 0) + self.barStack(bar: bar, barWidth: barWidth, maxTotal: self.maxTotal) + } + .frame(width: barWidth, height: self.barHeight) + .contentShape(Rectangle()) + .onHover { hovering in + self.hoveredBarIndex = hovering ? index : nil + } + .overlay(alignment: .bottom) { + if self.hoveredBarIndex == index { + self.tooltipOverlay(bar: bar) + } + } + } + } + .frame(height: self.barHeight) + } + .frame(height: self.barHeight) + + self.legend + self.xAxisLabels + } + } + .padding(.top, 6) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) + .animation(.easeInOut(duration: 0.2), value: self.isExpanded) + } + + private var maxTotal: Int { + self.bars.map(\.totalTokens).max() ?? 1 + } + + private var rangeToggle: some View { + Picker("", selection: Binding( + get: { self.selectedRange.rawValue }, + set: { self.selectedRange = RangeOption(rawValue: $0) ?? .today })) + { + Text("Today").tag(RangeOption.today.rawValue) + Text("24h").tag(RangeOption.last24h.rawValue) + } + .pickerStyle(.segmented) + .frame(width: 100) + .scaleEffect(0.8) + .frame(width: 80, height: 16) + } + + @ViewBuilder + private func barStack(bar: ZaiHourlyBar, barWidth: CGFloat, maxTotal: Int) -> some View { + let scaleFactor = CGFloat(bar.totalTokens) / CGFloat(max(maxTotal, 1)) + + VStack(spacing: 0) { + ForEach(Array(bar.segments.enumerated()), id: \.offset) { segIndex, segment in + let segFraction = CGFloat(segment.tokens) / CGFloat(max(bar.totalTokens, 1)) + let segHeight = max( + self.barHeight * scaleFactor * segFraction, + segment.tokens > 0 ? 1 : 0) + RoundedRectangle(cornerRadius: segIndex == bar.segments.count - 1 ? 2 : 0) + .fill(self.colorForModel(segment.model)) + .frame(height: segHeight) + } + } + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + + private func tooltipOverlay(bar: ZaiHourlyBar) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(bar.label + ":00") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary) + ForEach(Array(bar.segments.enumerated()), id: \.offset) { _, segment in + HStack(spacing: 3) { + Circle() + .fill(self.colorForModel(segment.model)) + .frame(width: 5, height: 5) + Text(segment.model) + .font(.system(size: 9)) + .foregroundColor(.primary) + .lineLimit(1) + .layoutPriority(1) + Text(self.formatTokenCount(segment.tokens)) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.primary) + } + } + Divider() + .background(Color.primary.opacity(0.15)) + Text(self.formatTokenCount(bar.totalTokens)) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.primary) + } + .padding(6) + .frame(minWidth: 90, maxWidth: 140) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.95)) + .background(.ultraThinMaterial) + .cornerRadius(6) + .shadow(color: .black.opacity(0.2), radius: 4, y: 2) + .offset(y: -self.barHeight - 8) + } + + private var legend: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(self.modelNames, id: \.self) { name in + HStack(spacing: 2) { + Circle() + .fill(self.colorForModel(name)) + .frame(width: 6, height: 6) + Text(name) + .font(.system(size: 9)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + } + + private var xAxisLabels: some View { + HStack(spacing: 0) { + ForEach(Array(self.labelIndices.enumerated()), id: \.offset) { _, index in + if index < self.bars.count { + Text(self.bars[index].label) + .font(.system(size: 9)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + } + } + } + + private var labelIndices: [Int] { + guard self.bars.count > self.maxLabelCount else { return Array(0.. Color { + let index = self.modelNames.firstIndex(of: name) ?? 0 + return self.colorPalette[index % self.colorPalette.count] + } + + private func formatTokenCount(_ count: Int) -> String { + if count >= 1_000_000 { + String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1000 { + String(format: "%.1fk", Double(count) / 1000) + } else { + "\(count)" + } + } +} diff --git a/Sources/CodexBarCLI/CLICacheCommand.swift b/Sources/CodexBarCLI/CLICacheCommand.swift new file mode 100644 index 000000000..4fe11aecd --- /dev/null +++ b/Sources/CodexBarCLI/CLICacheCommand.swift @@ -0,0 +1,142 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runCacheClear(_ values: ParsedValues) { + let output = CLIOutputPreferences.from(values: values) + let cookies = values.flags.contains("cookies") + let cost = values.flags.contains("cost") + let all = values.flags.contains("all") + let rawProvider = values.options["provider"]?.last + + let clearCookies = cookies || all + let clearCost = cost || all + + if !clearCookies, !clearCost { + Self.exit( + code: .failure, + message: "Specify --cookies, --cost, or --all.", + output: output, + kind: .args) + } + if let error = Self.cacheClearProviderScopeError(rawProvider: rawProvider, clearCost: clearCost) { + Self.exit(code: .failure, message: error, output: output, kind: .args) + } + + var results: [CacheClearResult] = [] + + if clearCookies { + if let rawProvider { + if let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] { + let cleared = CookieHeaderCache.clearAllScopes(provider: provider) + results.append(CacheClearResult( + cache: "cookies", + provider: provider.rawValue, + cleared: cleared)) + } else { + Self.exit( + code: .failure, + message: "Unknown provider: \(rawProvider)", + output: output, + kind: .args) + } + } else { + let cleared = CookieHeaderCache.clearAll() + results.append(CacheClearResult(cache: "cookies", provider: nil, cleared: cleared)) + } + } + + if clearCost { + let fm = FileManager.default + let cacheDir = Self.costUsageCacheDirectory(fileManager: fm) + var cleared = 0 + var costError: String? + if fm.fileExists(atPath: cacheDir.path) { + do { + try fm.removeItem(at: cacheDir) + cleared = 1 + } catch { + costError = error.localizedDescription + } + } + results.append(CacheClearResult(cache: "cost", provider: nil, cleared: cleared, error: costError)) + } + + switch output.format { + case .text: + for result in results { + let scope = result.provider ?? "all providers" + if let error = result.error { + print("\(result.cache): failed to clear (\(scope)) - \(error)") + } else if result.cleared > 0 { + print("\(result.cache): cleared (\(scope))") + } else { + print("\(result.cache): nothing to clear (\(scope))") + } + } + case .json: + Self.printJSON(results, pretty: output.pretty) + } + + let hasErrors = results.contains(where: { $0.error != nil }) + Self.exit(code: hasErrors ? .failure : .success, output: output, kind: .runtime) + } + + static func cacheClearProviderScopeError(rawProvider: String?, clearCost: Bool) -> String? { + guard rawProvider != nil, clearCost else { return nil } + return "--provider only scopes cookie caches. Use --cookies --provider , or omit --provider." + } +} + +struct CacheOptions: 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("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Flag(name: .long("cookies"), help: "Clear browser cookie caches") + var cookies: Bool = false + + @Flag(name: .long("cost"), help: "Clear cost usage caches") + var cost: Bool = false + + @Flag(name: .long("all"), help: "Clear all caches") + var all: Bool = false + + @Option(name: .long("provider"), help: "Clear cache for a specific provider only") + var provider: String? +} + +private struct CacheClearResult: Encodable { + let cache: String + let provider: String? + let cleared: Int + var error: String? +} + +extension CodexBarCLI { + /// Mirrors the cost usage cache directory used by the app (UsageStore.costUsageCacheDirectory). + static func costUsageCacheDirectory(fileManager: FileManager = .default) -> URL { + let root = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("cost-usage", isDirectory: true) + } +} diff --git a/Sources/CodexBarCLI/CLIConfigCommand.swift b/Sources/CodexBarCLI/CLIConfigCommand.swift index edc013874..81fdd96f8 100644 --- a/Sources/CodexBarCLI/CLIConfigCommand.swift +++ b/Sources/CodexBarCLI/CLIConfigCommand.swift @@ -35,6 +35,198 @@ extension CodexBarCLI { Self.printJSON(config, pretty: output.pretty) Self.exit(code: .success, output: output, kind: .config) } + + static func runConfigProviders(_ values: ParsedValues) { + let output = CLIOutputPreferences.from(values: values) + let config = Self.loadConfig(output: output) + let results = Self.configProviderStatuses(config) + + switch output.format { + case .text: + for result in results { + let state = result.enabled ? "enabled" : "disabled" + let marker = result.defaultEnabled ? " default" : "" + print("\(result.provider): \(state)\(marker) (\(result.displayName))") + } + case .json: + Self.printJSON(results, pretty: output.pretty) + } + + Self.exit(code: .success, output: output, kind: .config) + } + + static func runConfigSetProviderEnabled(_ values: ParsedValues, enabled: Bool) { + let output = CLIOutputPreferences.from(values: values) + guard let rawProvider = values.options["provider"]?.last, + let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] + else { + Self.exit( + code: .failure, + message: "Unknown or missing provider. Use --provider .", + output: output, + kind: .args) + } + + let store = CodexBarConfigStore() + var config = Self.loadConfig(output: output) + config = Self.configSettingProviderEnabled(config, provider: provider, enabled: enabled) + + do { + try store.save(config) + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .config) + } + + let metadata = ProviderDescriptorRegistry.descriptor(for: provider).metadata + let result = ConfigProviderToggleResult( + provider: provider.rawValue, + displayName: metadata.displayName, + enabled: enabled, + configPath: store.fileURL.path) + + switch output.format { + case .text: + let state = enabled ? "enabled" : "disabled" + print("Config: \(state) \(metadata.displayName)") + case .json: + Self.printJSON(result, pretty: output.pretty) + } + + Self.exit(code: .success, output: output, kind: .config) + } + + static func runConfigSetAPIKey(_ values: ParsedValues) { + let output = CLIOutputPreferences.from(values: values) + + guard let rawProvider = values.options["provider"]?.last, + let provider = ProviderDescriptorRegistry.cliNameMap[rawProvider.lowercased()] + else { + Self.exit( + code: .failure, + message: "Unknown or missing provider. Use --provider .", + output: output, + kind: .args) + } + guard ProviderConfigEnvironment.supportsAPIKeyOverride(for: provider) else { + Self.exit( + code: .failure, + message: "\(rawProvider) does not support config API keys.", + output: output, + kind: .args) + } + + let apiKey: String + do { + apiKey = try Self.resolveConfigAPIKeyInput( + apiKey: values.options["apiKey"]?.last, + readFromStdin: values.flags.contains("stdin")) + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .args) + } + + let enableProvider = !values.flags.contains("noEnable") + let store = CodexBarConfigStore() + var config = Self.loadConfig(output: output) + config = Self.configSettingAPIKey( + config, + provider: provider, + apiKey: apiKey, + enableProvider: enableProvider) + + do { + try store.save(config) + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .config) + } + + let result = ConfigSetAPIKeyResult( + provider: provider.rawValue, + enabled: config.providerConfig(for: provider)?.enabled ?? false, + configPath: store.fileURL.path) + + switch output.format { + case .text: + let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let suffix = result.enabled ? " and enabled" : "" + print("Config: stored API key for \(name)\(suffix)") + case .json: + Self.printJSON(result, pretty: output.pretty) + } + + Self.exit(code: .success, output: output, kind: .config) + } + + static func resolveConfigAPIKeyInput(apiKey: String?, readFromStdin: Bool) throws -> String { + if apiKey != nil, readFromStdin { + throw CLIArgumentError("Use either --api-key or --stdin, not both.") + } + + let raw: String? = if readFromStdin { + String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) + } else { + apiKey + } + + guard let value = Self.cleanConfigSecret(raw) else { + throw CLIArgumentError("Missing API key. Pass --api-key or pipe it with --stdin.") + } + return value + } + + static func configSettingAPIKey( + _ config: CodexBarConfig, + provider: UsageProvider, + apiKey: String, + enableProvider: Bool) -> CodexBarConfig + { + var updated = config.normalized() + var providerConfig = updated.providerConfig(for: provider) ?? ProviderConfig(id: provider) + providerConfig.apiKey = apiKey + if enableProvider { + providerConfig.enabled = true + } + updated.setProviderConfig(providerConfig) + return updated + } + + static func configSettingProviderEnabled( + _ config: CodexBarConfig, + provider: UsageProvider, + enabled: Bool) -> CodexBarConfig + { + var updated = config.normalized() + var providerConfig = updated.providerConfig(for: provider) ?? ProviderConfig(id: provider) + providerConfig.enabled = enabled + updated.setProviderConfig(providerConfig) + return updated + } + + static func configProviderStatuses(_ config: CodexBarConfig) -> [ConfigProviderStatusResult] { + let metadata = ProviderDescriptorRegistry.metadata + return config.normalized().providers.map { providerConfig in + let meta = metadata[providerConfig.id] + let defaultEnabled = meta?.defaultEnabled ?? false + return ConfigProviderStatusResult( + provider: providerConfig.id.rawValue, + displayName: meta?.displayName ?? providerConfig.id.rawValue, + enabled: providerConfig.enabled ?? defaultEnabled, + defaultEnabled: defaultEnabled) + } + } + + private static func cleanConfigSecret(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } } struct ConfigOptions: CommanderParsable { @@ -59,3 +251,84 @@ struct ConfigOptions: CommanderParsable { @Flag(name: .long("pretty"), help: "Pretty-print JSON output") var pretty: Bool = false } + +struct ConfigSetAPIKeyOptions: 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("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Option(name: .long("provider"), help: ProviderHelp.optionHelp) + var provider: String? + + @Option(name: .long("api-key"), help: "API key to store") + var apiKey: String? + + @Flag(name: .long("stdin"), help: "Read API key from stdin") + var stdin: Bool = false + + @Flag(name: .long("no-enable"), help: "Store the key without enabling the provider") + var noEnable: Bool = false +} + +struct ConfigProviderToggleOptions: 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("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Option(name: .long("provider"), help: ProviderHelp.optionHelp) + var provider: String? +} + +private struct ConfigSetAPIKeyResult: Encodable { + let provider: String + let enabled: Bool + let configPath: String +} + +struct ConfigProviderStatusResult: Encodable, Equatable { + let provider: String + let displayName: String + let enabled: Bool + let defaultEnabled: Bool +} + +private struct ConfigProviderToggleResult: Encodable { + let provider: String + let displayName: String + let enabled: Bool + let configPath: String +} diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index c3b82b0e2..5b4b263d3 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -79,7 +79,7 @@ extension CodexBarCLI { useColor: Bool) -> String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let header = Self.costHeaderLine("\(name) Cost (local)", useColor: useColor) + let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) let todayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } @@ -89,7 +89,8 @@ extension CodexBarCLI { let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) } let monthLine = monthTokens.map { "Last 30 days: \(monthCost) · \($0) tokens" } ?? "Last 30 days: \(monthCost)" - return [header, todayLine, monthLine].joined(separator: "\n") + let hintLine = UsageFormatter.costEstimateHint(provider: provider) + return [header, todayLine, monthLine, hintLine].joined(separator: "\n") } private static func costHeaderLine(_ header: String, useColor: Bool) -> String { @@ -97,11 +98,11 @@ extension CodexBarCLI { return "\u{001B}[1;36m\(header)\u{001B}[0m" } - private static func costProviders(from selection: ProviderSelection) -> [UsageProvider] { + static func costProviders(from selection: ProviderSelection) -> [UsageProvider] { selection.asList.filter { Self.costSupportedProviders.contains($0) } } - private static func makeCostPayload( + static func makeCostPayload( provider: UsageProvider, snapshot: CostUsageTokenSnapshot?, error: Error?) -> CostPayload diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 42957f717..f6b7aa061 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -1,8 +1,5 @@ import CodexBarCore import Commander -#if canImport(AppKit) -import AppKit -#endif #if canImport(Darwin) import Darwin #else @@ -39,10 +36,22 @@ enum CodexBarCLI { await self.runUsage(invocation.parsedValues) case ["cost"]: await self.runCost(invocation.parsedValues) + case ["serve"]: + await self.runServe(invocation.parsedValues) case ["config", "validate"]: self.runConfigValidate(invocation.parsedValues) case ["config", "dump"]: self.runConfigDump(invocation.parsedValues) + case ["config", "providers"]: + self.runConfigProviders(invocation.parsedValues) + case ["config", "enable"]: + self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: true) + case ["config", "disable"]: + self.runConfigSetProviderEnabled(invocation.parsedValues, enabled: false) + case ["config", "set-api-key"]: + self.runConfigSetAPIKey(invocation.parsedValues) + case ["cache", "clear"]: + self.runCacheClear(invocation.parsedValues) default: Self.exit( code: .failure, @@ -60,7 +69,11 @@ enum CodexBarCLI { private static func commandDescriptors() -> [CommandDescriptor] { let usageSignature = CommandSignature.describe(UsageOptions()) let costSignature = CommandSignature.describe(CostOptions()) + let serveSignature = CommandSignature.describe(ServeOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) + let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) + let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) + let cacheSignature = CommandSignature.describe(CacheOptions()) return [ CommandDescriptor( @@ -73,6 +86,11 @@ enum CodexBarCLI { abstract: "Print local cost usage as text or JSON", discussion: nil, signature: costSignature), + CommandDescriptor( + name: "serve", + abstract: "Serve usage and cost JSON over localhost HTTP", + discussion: nil, + signature: serveSignature), CommandDescriptor( name: "config", abstract: "Config utilities", @@ -89,8 +107,41 @@ enum CodexBarCLI { abstract: "Print normalized config JSON", discussion: nil, signature: configSignature), + CommandDescriptor( + name: "providers", + abstract: "List provider enablement", + discussion: nil, + signature: configSignature), + CommandDescriptor( + name: "enable", + abstract: "Enable a provider", + discussion: nil, + signature: configProviderToggleSignature), + CommandDescriptor( + name: "disable", + abstract: "Disable a provider", + discussion: nil, + signature: configProviderToggleSignature), + CommandDescriptor( + name: "set-api-key", + abstract: "Store a provider API key", + discussion: nil, + signature: configSetAPIKeySignature), ], defaultSubcommandName: "validate"), + CommandDescriptor( + name: "cache", + abstract: "Cache management", + discussion: nil, + signature: CommandSignature(), + subcommands: [ + CommandDescriptor( + name: "clear", + abstract: "Clear cached data (cookies, cost, or all)", + discussion: nil, + signature: cacheSignature), + ], + defaultSubcommandName: "clear"), ] } diff --git a/Sources/CodexBarCLI/CLIErrorReporting.swift b/Sources/CodexBarCLI/CLIErrorReporting.swift index f3599e037..954e11174 100644 --- a/Sources/CodexBarCLI/CLIErrorReporting.swift +++ b/Sources/CodexBarCLI/CLIErrorReporting.swift @@ -87,10 +87,10 @@ extension CodexBarCLI { output: CLIOutputPreferences? = nil, kind: CLIErrorKind = .runtime) -> Never { - if code != .success { + if self.shouldPrintExitError(code: code, message: message) { if let output, output.usesJSONOutput { let payload = self.makeCLIErrorPayload( - message: message ?? "Error", + message: message ?? "", code: code, kind: kind, pretty: output.pretty) @@ -104,6 +104,10 @@ extension CodexBarCLI { platformExit(code.rawValue) } + static func shouldPrintExitError(code: ExitCode, message: String?) -> Bool { + code != .success && message != nil + } + static func printError(_ error: Error, output: CLIOutputPreferences, kind: CLIErrorKind = .runtime) { if output.usesJSONOutput { let payload = ProviderPayload( diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index e399640dc..ebd2d0e88 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -71,6 +71,34 @@ extension CodexBarCLI { """ } + static func serveHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar serve [--port ] [--refresh-interval ] + [--json-output] [--log-level ] + [-v|--verbose] + + Description: + Start a foreground localhost-only HTTP server that exposes existing CLI JSON payloads. + The server binds to 127.0.0.1 only in this initial version. + + Endpoints: + GET /health + GET /usage + GET /usage?provider=claude + GET /usage?provider=all + GET /cost + GET /cost?provider=codex + + Examples: + codexbar serve + codexbar serve --port 8080 --refresh-interval 60 + curl http://127.0.0.1:8080/usage?provider=all + """ + } + static func configHelp(version: String) -> String { """ CodexBar \(version) @@ -88,13 +116,54 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--pretty] + codexbar config providers [--format text|json] [--json] [--json-only] [--pretty] + codexbar config enable --provider [--format text|json] [--json] [--json-only] [--pretty] + codexbar config disable --provider [--format text|json] [--json] [--json-only] [--pretty] + codexbar config set-api-key --provider (--api-key |--stdin) + [--no-enable] + [--format text|json] [--json] [--json-only] [--pretty] Description: Validate or print the CodexBar config file (default: validate). + providers lists persistent provider enablement. + enable/disable updates the same provider toggle used by Settings. + set-api-key stores a provider API key in ~/.codexbar/config.json and enables that provider by default. Examples: codexbar config validate --format json --pretty codexbar config dump --pretty + codexbar config providers + codexbar config enable --provider grok + codexbar config disable --provider cursor + printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin + """ + } + + static func cacheHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar cache clear <--cookies|--cost|--all> + [--provider ] + [--format text|json] + [--json] + [--json-only] + [--json-output] [--log-level ] + [-v|--verbose] + [--pretty] + + Description: + Clear cached data. Use --cookies to clear browser cookie caches (stored in Keychain), + --cost to clear cost usage scan caches, or --all for both. + Optionally specify --provider with --cookies to clear cookies for a single provider only. + + Examples: + codexbar cache clear --cookies + codexbar cache clear --cookies --provider claude + codexbar cache clear --cost + codexbar cache clear --all + codexbar cache clear --all --format json --pretty """ } @@ -116,12 +185,18 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] - codexbar config [--format text|json] + codexbar serve [--port ] [--refresh-interval ] + [--json-output] [--log-level ] [-v|--verbose] + codexbar config [--format text|json] [--json] [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--pretty] + codexbar config enable --provider + codexbar config disable --provider + codexbar config set-api-key --provider (--api-key |--stdin) + codexbar cache clear <--cookies|--cost|--all> [--provider ] Global flags: -h, --help Show help @@ -137,7 +212,11 @@ extension CodexBarCLI { codexbar --provider all --json codexbar --provider gemini codexbar cost --provider claude --format json --pretty + codexbar serve --port 8080 codexbar config validate --format json --pretty + codexbar config enable --provider grok + codexbar config set-api-key --provider elevenlabs --stdin + codexbar cache clear --cookies """ } } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 0a6663b94..c4444d8c9 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -17,7 +17,6 @@ extension CodexBarCLI { if let rawOverride, let parsed = ProviderSelection(argument: rawOverride) { return parsed } - if enabled.count >= 3 { return .all } if enabled.count == 2 { let enabledSet = Set(enabled) let primary = Set(ProviderDescriptorRegistry.all.filter(\ .metadata.isPrimaryProvider).map(\ .id)) @@ -26,8 +25,9 @@ extension CodexBarCLI { } return .custom(enabled) } + if enabled.count >= 3 { return .custom(enabled) } if let first = enabled.first { return ProviderSelection(provider: first) } - return .single(.codex) + return .custom([]) } static func decodeFormat(from values: ParsedValues) -> OutputFormat { @@ -362,6 +362,18 @@ extension CodexBarCLI { CommandSignature.describe(CostOptions()) } + static func _cacheSignatureForTesting() -> CommandSignature { + CommandSignature.describe(CacheOptions()) + } + + static func _configSetAPIKeySignatureForTesting() -> CommandSignature { + CommandSignature.describe(ConfigSetAPIKeyOptions()) + } + + static func _configProviderToggleSignatureForTesting() -> CommandSignature { + CommandSignature.describe(ConfigProviderToggleOptions()) + } + static func _decodeFormatForTesting(from values: ParsedValues) -> OutputFormat { self.decodeFormat(from: values) } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 160d0ef13..49242dabf 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -27,8 +27,12 @@ extension CodexBarCLI { print(Self.usageHelp(version: version)) case "cost": print(Self.costHelp(version: version)) + case "serve": + print(Self.serveHelp(version: version)) case "config", "validate", "dump": print(Self.configHelp(version: version)) + case "cache", "clear": + print(Self.cacheHelp(version: version)) default: print(Self.rootHelp(version: version)) } @@ -39,13 +43,25 @@ extension CodexBarCLI { bundle: Bundle = .main, executablePath: String? = CommandLine.arguments.first) -> String? { - if let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String { + if let version = self.currentVersion(bundleVersion: nil, executablePath: executablePath) { return version } - guard let executablePath, !executablePath.isEmpty else { return nil } + return self.currentVersion( + bundleVersion: bundle.infoDictionary?["CFBundleShortVersionString"] as? String, + executablePath: nil) + } - let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() - return Self.containingAppVersion(for: executableURL) + static func currentVersion(bundleVersion: String?, executablePath: String?) -> String? { + if let executablePath, !executablePath.isEmpty { + let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() + if let version = Self.adjacentVersionFileVersion(for: executableURL) { + return version + } + if let version = Self.containingAppVersion(for: executableURL) { + return version + } + } + return Self.normalizedBundleVersion(bundleVersion) } static func containingAppVersion(for executableURL: URL) -> String? { @@ -68,6 +84,29 @@ extension CodexBarCLI { return nil } + static func adjacentVersionFileVersion(for executableURL: URL) -> String? { + let versionURL = executableURL + .deletingLastPathComponent() + .appendingPathComponent("VERSION") + guard let raw = try? String(contentsOf: versionURL, encoding: .utf8) else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("v"), trimmed.dropFirst().first?.isNumber == true { + return String(trimmed.dropFirst()) + } + return trimmed + } + + static func normalizedBundleVersion(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + trimmed != "CodexBar" + else { return nil } + return trimmed + } + static func platformExit(_ code: Int32) -> Never { #if canImport(Darwin) Darwin.exit(code) diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift new file mode 100644 index 000000000..65a486db4 --- /dev/null +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -0,0 +1,361 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +private let requestReadTimeoutMilliseconds: Int32 = 5000 + +struct CLILocalHTTPRequest { + let method: String + let target: String + let host: String + let path: String + let queryItems: [String: String] + + static func parse(_ data: Data) -> Result { + guard let raw = String(data: data, encoding: .utf8), + let firstLine = raw.components(separatedBy: "\r\n").first + else { + return .failure(.invalidRequest) + } + + let parts = firstLine.split(separator: " ") + guard parts.count >= 3 else { return .failure(.invalidRequest) } + + let method = String(parts[0]).uppercased() + let target = String(parts[1]) + guard target.hasPrefix("/") else { return .failure(.invalidRequest) } + + let headerResult = Self.parseHeaders(raw) + let host: String + switch headerResult { + case let .success(headers): + let hosts = headers.compactMap { name, value in + name.lowercased() == "host" ? value : nil + } + guard let candidate = hosts.first else { return .failure(.missingHost) } + guard hosts.count == 1 else { return .failure(.duplicateHost) } + guard Self.isAllowedLoopbackHost(candidate) else { return .failure(.disallowedHost) } + host = candidate + case let .failure(error): + return .failure(error) + } + + let components = URLComponents(string: "http://localhost\(target)") + let path = components?.path ?? target + var queryItems: [String: String] = [:] + for item in components?.queryItems ?? [] { + if let value = item.value { + queryItems[item.name] = value + } + } + + return .success(CLILocalHTTPRequest( + method: method, + target: target, + host: host, + path: path, + queryItems: queryItems)) + } + + private static func parseHeaders(_ raw: String) -> Result<[(String, String)], CLILocalHTTPRequestParseError> { + let lines = raw.components(separatedBy: "\r\n") + var headers: [(String, String)] = [] + + for line in lines.dropFirst() { + if line.isEmpty { break } + guard let separator = line.firstIndex(of: ":") else { + return .failure(.invalidRequest) + } + let name = String(line[.. Bool { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains(",") else { return false } + + let hostWithoutPort: String + if trimmed.hasPrefix("[") { + guard let closingBracket = trimmed.firstIndex(of: "]") else { return false } + hostWithoutPort = String(trimmed[...closingBracket]) + let remainder = trimmed[trimmed.index(after: closingBracket)...] + guard remainder.isEmpty || Self.isValidPortSuffix(String(remainder)) else { return false } + } else { + let segments = trimmed.split(separator: ":", omittingEmptySubsequences: false) + switch segments.count { + case 1: + hostWithoutPort = String(segments[0]) + case 2: + guard Self.isValidPort(String(segments[1])) else { return false } + hostWithoutPort = String(segments[0]) + default: + return false + } + } + + switch hostWithoutPort.lowercased() { + case "127.0.0.1", "localhost", "localhost.", "[::1]": + return true + default: + return false + } + } + + private static func isValidPortSuffix(_ raw: String) -> Bool { + guard raw.hasPrefix(":") else { return false } + return self.isValidPort(String(raw.dropFirst())) + } + + private static func isValidPort(_ raw: String) -> Bool { + guard let port = Int(raw), port > 0, port <= Int(UInt16.max) else { return false } + return true + } +} + +enum CLILocalHTTPRequestParseError: Error, Equatable { + case invalidRequest + case missingHost + case duplicateHost + case disallowedHost +} + +enum CLIHTTPStatus { + case ok + case badRequest + case forbidden + case notFound + case methodNotAllowed + case internalServerError + + var code: Int { + switch self { + case .ok: 200 + case .badRequest: 400 + case .forbidden: 403 + case .notFound: 404 + case .methodNotAllowed: 405 + case .internalServerError: 500 + } + } + + var reason: String { + switch self { + case .ok: "OK" + case .badRequest: "Bad Request" + case .forbidden: "Forbidden" + case .notFound: "Not Found" + case .methodNotAllowed: "Method Not Allowed" + case .internalServerError: "Internal Server Error" + } + } +} + +struct CLILocalHTTPResponse { + let status: CLIHTTPStatus + let body: Data + let contentType: String + + init(status: CLIHTTPStatus, body: Data, contentType: String = "application/json; charset=utf-8") { + self.status = status + self.body = body + self.contentType = contentType + } + + var serialized: Data { + var headers = "HTTP/1.1 \(self.status.code) \(self.status.reason)\r\n" + headers += "Content-Type: \(self.contentType)\r\n" + headers += "Content-Length: \(self.body.count)\r\n" + headers += "Connection: close\r\n" + headers += "\r\n" + + var data = Data(headers.utf8) + data.append(self.body) + return data + } +} + +final class CLILocalHTTPServer { + typealias Handler = @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse + + private let host: String + private let port: UInt16 + private let handler: Handler + + init(host: String, port: UInt16, handler: @escaping Handler) { + self.host = host + self.port = port + self.handler = handler + } + + func run(onListening: @Sendable () -> Void = {}) async throws { + ignoreSIGPIPE() + + #if canImport(Darwin) + let streamType = SOCK_STREAM + #else + let streamType = Int32(SOCK_STREAM.rawValue) + #endif + + let serverFD = socket(AF_INET, streamType, 0) + guard serverFD >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { closeSocket(serverFD) } + + var reuse: Int32 = 1 + setsockopt( + serverFD, + SOL_SOCKET, + SO_REUSEADDR, + &reuse, + socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + #if canImport(Darwin) + address.sin_len = UInt8(MemoryLayout.size) + #endif + address.sin_family = sa_family_t(AF_INET) + address.sin_port = self.port.bigEndian + guard inet_pton(AF_INET, self.host, &address.sin_addr) == 1 else { + throw POSIXError(.EADDRNOTAVAIL) + } + + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + bind(serverFD, socketAddress, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + guard listen(serverFD, 16) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + onListening() + + while true { + var clientAddress = sockaddr() + var clientLength = socklen_t(MemoryLayout.size) + let clientFD = accept(serverFD, &clientAddress, &clientLength) + guard clientFD >= 0 else { continue } + let handler = self.handler + Task { + defer { closeSocket(clientFD) } + await handleClient(clientFD, handler: handler) + } + } + } +} + +private func handleClient( + _ clientFD: Int32, + handler: @Sendable (CLILocalHTTPRequest) async -> CLILocalHTTPResponse) async +{ + let request: CLILocalHTTPRequest + switch readRequest(clientFD) { + case let .success(parsedRequest): + request = parsedRequest + case .failure(.disallowedHost): + sendResponse( + CLILocalHTTPResponse( + status: .forbidden, + body: Data(#"{"error":"forbidden host"}"#.utf8)), + to: clientFD) + return + case .failure: + sendResponse( + CLILocalHTTPResponse( + status: .badRequest, + body: Data(#"{"error":"invalid request"}"#.utf8)), + to: clientFD) + return + } + + let response = await handler(request) + sendResponse(response, to: clientFD) +} + +private func readRequest(_ fd: Int32) -> Result { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + let bufferSize = buffer.count + var sawHeaderEnd = false + + while data.count < 16384 { + guard waitForReadable(fd, timeoutMilliseconds: requestReadTimeoutMilliseconds) else { + return .failure(.invalidRequest) + } + let count = buffer.withUnsafeMutableBytes { rawBuffer in + recv(fd, rawBuffer.baseAddress, bufferSize, 0) + } + guard count > 0 else { break } + data.append(buffer, count: count) + if data.range(of: Data("\r\n\r\n".utf8)) != nil { + sawHeaderEnd = true + break + } + } + + guard sawHeaderEnd else { return .failure(.invalidRequest) } + return CLILocalHTTPRequest.parse(data) +} + +private func sendResponse(_ response: CLILocalHTTPResponse, to fd: Int32) { + let data = response.serialized + data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var sent = 0 + while sent < data.count { + let count = send(fd, base.advanced(by: sent), data.count - sent, sendNoSignalFlags()) + guard count > 0 else { break } + sent += count + } + } +} + +private func waitForReadable(_ fd: Int32, timeoutMilliseconds: Int32) -> Bool { + var pollFD = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + while true { + let result = poll(&pollFD, 1, timeoutMilliseconds) + if result > 0 { + return (pollFD.revents & Int16(POLLIN)) != 0 + } + if result == -1, errno == EINTR { + continue + } + return false + } +} + +private func sendNoSignalFlags() -> Int32 { + #if canImport(Darwin) + 0 + #else + Int32(MSG_NOSIGNAL) + #endif +} + +private func ignoreSIGPIPE() { + #if canImport(Darwin) + _ = Darwin.signal(SIGPIPE, SIG_IGN) + #else + _ = Glibc.signal(SIGPIPE, SIG_IGN) + #endif +} + +private func closeSocket(_ fd: Int32) { + #if canImport(Darwin) + Darwin.close(fd) + #else + Glibc.close(fd) + #endif +} diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index e22260c8d..a5171ea95 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -15,24 +15,31 @@ enum CLIRenderer { context: RenderContext) -> String { let meta = ProviderDescriptorRegistry.descriptor(for: provider).metadata + let labels = self.rateWindowLabels(provider: provider, metadata: meta, snapshot: snapshot) let now = Date() var lines: [String] = [] lines.append(self.headerLine(context.header, useColor: context.useColor)) self.appendPrimaryLines( provider: provider, snapshot: snapshot, - metadata: meta, + labels: labels, context: context, now: now, lines: &lines) self.appendSecondaryLines( provider: provider, snapshot: snapshot, - metadata: meta, + labels: labels, context: context, now: now, lines: &lines) - self.appendTertiaryLines(snapshot: snapshot, metadata: meta, context: context, now: now, lines: &lines) + self.appendTertiaryLines(snapshot: snapshot, labels: labels, context: context, now: now, lines: &lines) + self.appendDeepgramLines(snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendLimitsUnavailableLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendCreditsLine(provider: provider, credits: credits, useColor: context.useColor, lines: &lines) self.appendIdentityAndNotes( provider: provider, @@ -62,7 +69,7 @@ enum CLIRenderer { private static func appendPrimaryLines( provider: UsageProvider, snapshot: UsageSnapshot, - metadata: ProviderMetadata, + labels: RateWindowLabels, context: RenderContext, now: Date, lines: inout [String]) @@ -70,7 +77,7 @@ enum CLIRenderer { if let primary = snapshot.primary { self.appendRateWindowLines( provider: provider, - title: metadata.sessionLabel, + title: labels.primary, window: primary, includePace: false, context: context, @@ -90,7 +97,7 @@ enum CLIRenderer { private static func appendSecondaryLines( provider: UsageProvider, snapshot: UsageSnapshot, - metadata: ProviderMetadata, + labels: RateWindowLabels, context: RenderContext, now: Date, lines: inout [String]) @@ -98,7 +105,7 @@ enum CLIRenderer { guard let weekly = snapshot.secondary else { return } self.appendRateWindowLines( provider: provider, - title: metadata.weeklyLabel, + title: labels.secondary, window: weekly, includePace: true, context: context, @@ -108,18 +115,64 @@ enum CLIRenderer { private static func appendTertiaryLines( snapshot: UsageSnapshot, - metadata: ProviderMetadata, + labels: RateWindowLabels, context: RenderContext, now: Date, lines: inout [String]) { - guard metadata.supportsOpus, let opus = snapshot.tertiary else { return } - lines.append(self.rateLine(title: metadata.opusLabel ?? "Sonnet", window: opus, useColor: context.useColor)) + guard labels.showsTertiary, let opus = snapshot.tertiary else { return } + lines.append(self.rateLine(title: labels.tertiary, window: opus, useColor: context.useColor)) if let reset = self.resetLine(for: opus, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } } + private static func appendDeepgramLines( + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard let usage = snapshot.deepgramUsage else { return } + for line in usage.displayLines { + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count == 2 { + lines.append(self.labelValueLine( + parts[0].trimmingCharacters(in: .whitespacesAndNewlines), + value: parts[1].trimmingCharacters(in: .whitespacesAndNewlines), + useColor: useColor)) + } else { + lines.append(self.labelValueLine("Usage", value: line, useColor: useColor)) + } + } + } + + private struct RateWindowLabels { + let primary: String + let secondary: String + let tertiary: String + let showsTertiary: Bool + } + + private static func rateWindowLabels( + provider: UsageProvider, + metadata: ProviderMetadata, + snapshot: UsageSnapshot) -> RateWindowLabels + { + if provider == .factory, snapshot.tertiary != nil { + return RateWindowLabels( + primary: "5-hour", + secondary: "Weekly", + tertiary: "Monthly", + showsTertiary: true) + } + + return RateWindowLabels( + primary: metadata.sessionLabel, + secondary: metadata.weeklyLabel, + tertiary: metadata.opusLabel ?? "Sonnet", + showsTertiary: metadata.supportsOpus) + } + private static func appendCreditsLine( provider: UsageProvider, credits: CreditsSnapshot?, @@ -133,6 +186,16 @@ enum CLIRenderer { useColor: useColor)) } + private static func appendLimitsUnavailableLine( + provider: UsageProvider, + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard snapshot.rateLimitsUnavailable(for: provider) else { return } + lines.append(self.labelValueLine("Limits", value: "not available", useColor: useColor)) + } + private static func appendIdentityAndNotes( provider: UsageProvider, snapshot: UsageSnapshot, @@ -199,7 +262,9 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if provider == .warp || provider == .kilo || provider == .mistral { + if provider == .warp || provider == .kilo || provider == .mistral || provider == .deepseek || + provider == .crof + { if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } @@ -223,7 +288,7 @@ enum CLIRenderer { style: ResetTimeDisplayStyle, now: Date) -> String? { - // Warp/Kilo use resetDescription for non-reset detail. + // Some provider snapshots use resetDescription for non-reset detail. // Only render "Resets ..." when a concrete reset date exists. guard window.resetsAt != nil else { return nil } let resetOnlyWindow = RateWindow( diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift new file mode 100644 index 000000000..70aeefc8a --- /dev/null +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -0,0 +1,340 @@ +import CodexBarCore +import Commander +import Foundation + +struct ServeOptions: 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("port"), help: "Local HTTP port (default: 8080)") + var port: Int? + + @Option(name: .long("refresh-interval"), help: "Response cache TTL in seconds (default: 60)") + var refreshInterval: Double? +} + +enum CLIServeRoute: Equatable { + case health + case usage(provider: String?) + case cost(provider: String?) +} + +enum CLIServeRouteError: Error, Equatable { + case methodNotAllowed + case notFound +} + +enum CLIServeRouter { + static func route(method: String, path: String, queryItems: [String: String]) throws -> CLIServeRoute { + guard method.uppercased() == "GET" else { + throw CLIServeRouteError.methodNotAllowed + } + + let provider = queryItems["provider"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedProvider = provider?.isEmpty == false ? provider : nil + + switch path { + case "/health": + return .health + case "/usage": + return .usage(provider: normalizedProvider) + case "/cost": + return .cost(provider: normalizedProvider) + default: + throw CLIServeRouteError.notFound + } + } +} + +private struct ServeErrorPayload: Encodable { + let error: String +} + +private struct ServeHealthPayload: Encodable { + let status: String +} + +private actor CLIServeResponseCache { + private struct Entry { + let expiresAt: Date + let response: CLILocalHTTPResponse + } + + private var entries: [String: Entry] = [:] + + 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 + return nil + } + return entry.response + } + + 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) + } +} + +private enum CLIServeArgumentError: LocalizedError { + case invalidPort + case invalidRefreshInterval + case invalidProvider(String) + + var errorDescription: String? { + switch self { + case .invalidPort: + "--port must be between 1 and 65535." + case .invalidRefreshInterval: + "--refresh-interval must be zero or greater." + case let .invalidProvider(provider): + "Unknown provider '\(provider)'." + } + } +} + +extension CodexBarCLI { + 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) + + guard let port else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidPort.localizedDescription, + output: output, + kind: .args) + } + + guard let refreshInterval else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidRefreshInterval.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 + await Self.handleServeRequest( + request, + config: config, + cache: cache, + refreshInterval: refreshInterval) + } + + do { + try await server.run { + Self.writeStderr("CodexBar server listening on http://127.0.0.1:\(port)\n") + } + } catch { + Self.exit(code: .failure, message: error.localizedDescription, output: output, kind: .runtime) + } + } + + static func decodeServePort(from values: ParsedValues) -> UInt16? { + let raw = values.options["port"]?.last + let parsed: Int + if let raw { + guard let value = Int(raw) else { return nil } + parsed = value + } else { + parsed = 8080 + } + guard parsed > 0, parsed <= Int(UInt16.max) else { return nil } + return UInt16(parsed) + } + + static func decodeServeRefreshInterval(from values: ParsedValues) -> TimeInterval? { + let raw = values.options["refreshInterval"]?.last + let parsed: Double + if let raw { + guard let value = Double(raw) else { return nil } + parsed = value + } else { + parsed = 60 + } + guard parsed >= 0 else { return nil } + return parsed + } + + private static func handleServeRequest( + _ request: CLILocalHTTPRequest, + config: CodexBarConfig, + cache: CLIServeResponseCache, + refreshInterval: TimeInterval) async -> CLILocalHTTPResponse + { + let route: CLIServeRoute + do { + route = try CLIServeRouter.route( + method: request.method, + path: request.path, + queryItems: request.queryItems) + } catch CLIServeRouteError.methodNotAllowed { + return Self.serveError(status: .methodNotAllowed, message: "method not allowed") + } catch { + return Self.serveError(status: .notFound, message: "not found") + } + + switch route { + case .health: + return Self.serveJSON(ServeHealthPayload(status: "ok")) + case let .usage(provider): + return await Self.cachedServeResponse( + key: "usage:\(provider ?? "")", + cache: cache, + refreshInterval: refreshInterval) + { + await Self.serveUsage(provider: provider, config: config) + } + case let .cost(provider): + return await Self.cachedServeResponse( + key: "cost:\(provider ?? "")", + cache: cache, + refreshInterval: refreshInterval) + { + await Self.serveCost(provider: provider, config: config) + } + } + } + + private static func cachedServeResponse( + key: String, + cache: CLIServeResponseCache, + refreshInterval: TimeInterval, + makeResponse: () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + { + let now = Date() + if let cached = await cache.response(for: key, now: now) { + return cached + } + + let response = await makeResponse() + if Self.shouldCacheServeResponse(response) { + await cache.store(response, for: key, ttl: refreshInterval, now: now) + } + return response + } + + static func shouldCacheServeResponse(_ response: CLILocalHTTPResponse) -> Bool { + guard response.status == .ok else { return false } + guard let payload = try? JSONSerialization.jsonObject(with: response.body) as? [[String: Any]] else { + return true + } + return !payload.contains { item in + guard let error = item["error"] else { return false } + return !(error is NSNull) + } + } + + private static func serveUsage( + provider rawProvider: String?, + config: CodexBarConfig) async -> CLILocalHTTPResponse + { + let selection: ProviderSelection + do { + selection = try Self.serveProviderSelection(rawProvider: rawProvider, config: config) + } catch { + return Self.serveError(status: .badRequest, message: error.localizedDescription) + } + + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + } catch { + return Self.serveError(status: .internalServerError, message: error.localizedDescription) + } + + let browserDetection = BrowserDetection() + let command = UsageCommandContext( + format: .json, + includeCredits: true, + sourceModeOverride: nil, + antigravityPlanDebug: false, + augmentDebug: false, + webDebugDumpHTML: false, + webTimeout: 60, + verbose: false, + useColor: false, + resetStyle: Self.resetTimeDisplayStyleFromDefaults(), + jsonOnly: true, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + var output = UsageCommandOutput() + for provider in selection.asList { + let providerOutput = await ProviderInteractionContext.$current.withValue(.background) { + await Self.fetchUsageOutputs( + provider: provider, + status: nil, + tokenContext: tokenContext, + command: command) + } + output.merge(providerOutput) + } + + return Self.serveJSON(output.payload) + } + + private static func serveCost(provider rawProvider: String?, config: CodexBarConfig) async -> CLILocalHTTPResponse { + let selection: ProviderSelection + do { + selection = try Self.serveProviderSelection(rawProvider: rawProvider, config: config) + } catch { + return Self.serveError(status: .badRequest, message: error.localizedDescription) + } + + let providers = Self.costProviders(from: selection) + guard !providers.isEmpty else { + return Self.serveError(status: .badRequest, message: "cost is only supported for Claude and Codex") + } + + let fetcher = CostUsageFetcher() + var payload: [CostPayload] = [] + for provider in providers { + do { + let snapshot = try await fetcher.loadTokenSnapshot(provider: provider, forceRefresh: false) + payload.append(Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil)) + } catch { + payload.append(Self.makeCostPayload(provider: provider, snapshot: nil, error: error)) + } + } + + return Self.serveJSON(payload) + } + + private static func serveProviderSelection( + rawProvider: String?, + config: CodexBarConfig) throws -> ProviderSelection + { + guard let rawProvider, !rawProvider.isEmpty else { + return providerSelection(rawOverride: nil, enabled: config.enabledProviders()) + } + guard let selection = ProviderSelection(argument: rawProvider) else { + throw CLIServeArgumentError.invalidProvider(rawProvider) + } + return selection + } + + private static func serveJSON(_ payload: some Encodable, status: CLIHTTPStatus = .ok) -> CLILocalHTTPResponse { + let json = Self.encodeJSON(payload, pretty: false) ?? "{}" + return CLILocalHTTPResponse(status: status, body: Data(json.utf8)) + } + + private static func serveError(status: CLIHTTPStatus, message: String) -> CLILocalHTTPResponse { + self.serveJSON(ServeErrorPayload(error: message), status: status) + } +} diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 7a9568166..33a112af9 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -164,9 +164,7 @@ extension CodexBarCLI { print(sections.joined(separator: "\n\n")) } case .json: - if !payload.isEmpty { - Self.printJSON(payload, pretty: output.pretty) - } + Self.printJSON(payload, pretty: output.pretty) } Self.exit(code: exitCode, output: output, kind: exitCode == .success ? .runtime : .provider) @@ -432,7 +430,10 @@ extension CodexBarCLI { } static func sourceModeRequiresWebSupport(_ sourceMode: ProviderSourceMode, provider: UsageProvider) -> Bool { - switch sourceMode { + guard provider != .grok else { + return false + } + return switch sourceMode { case .web: true case .auto: diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 5d4f9cba2..0dced1817 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -33,10 +33,20 @@ struct TokenAccountCLIContext { let selection: TokenAccountCLISelection let config: CodexBarConfig let accountsByProvider: [UsageProvider: ProviderTokenAccountData] + private let baseEnvironment: [String: String] + private let managedCodexAccountStoreURL: URL? - init(selection: TokenAccountCLISelection, config: CodexBarConfig, verbose _: Bool) throws { + init( + selection: TokenAccountCLISelection, + config: CodexBarConfig, + verbose _: Bool, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + managedCodexAccountStoreURL: URL? = nil) throws + { self.selection = selection self.config = config + self.baseEnvironment = baseEnvironment + self.managedCodexAccountStoreURL = managedCodexAccountStoreURL self.accountsByProvider = Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in guard let accounts = provider.tokenAccounts else { return nil } return (provider.id, accounts) @@ -77,14 +87,23 @@ struct TokenAccountCLIContext { func settingsSnapshot(for provider: UsageProvider, account: ProviderTokenAccount?) -> ProviderSettingsSnapshot? { let config = self.providerConfig(for: provider) + if let snapshot = self.makeCookieBackedSnapshot(provider: provider, account: account, config: config) { + return snapshot + } switch provider { case .codex: return self.makeSnapshot(codex: self.makeCodexSettingsSnapshot(account: account)) case .claude: let routing = self.claudeCredentialRouting(account: account, config: config) - let claudeSource: ClaudeUsageDataSource = routing.isOAuth ? .oauth : .auto - let cookieSource = routing.isOAuth + let claudeSource: ClaudeUsageDataSource = if routing.adminAPIKey != nil { + .api + } else if routing.isOAuth { + .oauth + } else { + .auto + } + let cookieSource = routing.isOAuth || routing.adminAPIKey != nil ? ProviderCookieSource.off : self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( @@ -92,115 +111,127 @@ struct TokenAccountCLIContext { usageDataSource: claudeSource, webExtrasEnabled: false, cookieSource: cookieSource, - manualCookieHeader: routing.manualCookieHeader)) + manualCookieHeader: routing.manualCookieHeader, + organizationID: account?.sanitizedOrganizationID)) + case .zai: + return self.makeSnapshot( + zai: ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.resolveZaiRegion(config))) + case .moonshot: + return self.makeSnapshot( + moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings( + region: self.resolveMoonshotRegion(config))) + case .kilo: + return self.makeSnapshot( + kilo: ProviderSettingsSnapshot.KiloProviderSettings( + usageDataSource: Self.kiloUsageDataSource(from: config?.source), + extrasEnabled: Self.kiloExtrasEnabled(from: config))) + case .jetbrains: + return self.makeSnapshot( + jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( + ideBasePath: nil)) + default: + return nil + } + } + + private func makeCookieBackedSnapshot( + provider: UsageProvider, + account: ProviderTokenAccount?, + config: ProviderConfig?) -> ProviderSettingsSnapshot? + { + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + + switch provider { case .cursor: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( cursor: ProviderSettingsSnapshot.CursorProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .opencode: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, workspaceID: config?.workspaceID)) case .opencodego: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, workspaceID: config?.workspaceID)) case .alibaba: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, apiRegion: self.resolveAlibabaCodingPlanRegion(config))) case .factory: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( factory: ProviderSettingsSnapshot.FactoryProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .minimax: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader, apiRegion: self.resolveMiniMaxRegion(config))) + case .manus: + return self.makeSnapshot( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .augment: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( augment: ProviderSettingsSnapshot.AugmentProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .amp: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( amp: ProviderSettingsSnapshot.AmpProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .ollama: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( ollama: ProviderSettingsSnapshot.OllamaProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .kimi: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( kimi: ProviderSettingsSnapshot.KimiProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - case .zai: - return self.makeSnapshot( - zai: ProviderSettingsSnapshot.ZaiProviderSettings(apiRegion: self.resolveZaiRegion(config))) - case .kilo: - return self.makeSnapshot( - kilo: ProviderSettingsSnapshot.KiloProviderSettings( - usageDataSource: Self.kiloUsageDataSource(from: config?.source), - extrasEnabled: Self.kiloExtrasEnabled(from: config))) - case .jetbrains: - return self.makeSnapshot( - jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( - ideBasePath: nil)) case .perplexity: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) + case .mimo: + return self.makeSnapshot( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) + case .doubao: + return nil case .abacus: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( abacus: ProviderSettingsSnapshot.AbacusProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) case .mistral: - let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) - let cookieSource = self.cookieSource(provider: provider, account: account, config: config) return self.makeSnapshot( mistral: ProviderSettingsSnapshot.MistralProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .stepfun: + return self.makeSnapshot( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: cookieSource, + manualToken: cookieHeader ?? "", + username: config?.sanitizedAPIKey ?? "", + password: "")) + default: return nil } } @@ -214,7 +245,9 @@ struct TokenAccountCLIContext { alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil, factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil, minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil, + manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil, zai: ProviderSettingsSnapshot.ZaiProviderSettings? = nil, + moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? = nil, kilo: ProviderSettingsSnapshot.KiloProviderSettings? = nil, kimi: ProviderSettingsSnapshot.KimiProviderSettings? = nil, augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, @@ -222,8 +255,10 @@ struct TokenAccountCLIContext { ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil, + mimo: ProviderSettingsSnapshot.MiMoProviderSettings? = nil, abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil, - mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot + mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil, + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -234,16 +269,20 @@ struct TokenAccountCLIContext { alibaba: alibaba, factory: factory, minimax: minimax, + manus: manus, zai: zai, kilo: kilo, kimi: kimi, augment: augment, + moonshot: moonshot, amp: amp, ollama: ollama, jetbrains: jetbrains, perplexity: perplexity, + mimo: mimo, abacus: abacus, - mistral: mistral) + mistral: mistral, + stepfun: stepfun) } private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) -> @@ -271,11 +310,15 @@ struct TokenAccountCLIContext { provider: provider, config: providerConfig) // If token account is selected, use its token instead of config's apiKey - if let account, - let override = TokenAccountSupportCatalog.envOverride(for: provider, token: account.token) - { - for (key, value) in override { - env[key] = value + if let account { + TokenAccountSupportCatalog.scrubEnvironmentForSelectedAccount( + &env, + provider: provider, + token: account.token) + if let override = TokenAccountSupportCatalog.envOverride(for: provider, token: account.token) { + for (key, value) in override { + env[key] = value + } } } return env @@ -304,13 +347,34 @@ struct TokenAccountCLIContext { provider: UsageProvider, account: ProviderTokenAccount?) -> ProviderSourceMode { - guard base == .auto, - provider == .claude - else { + guard provider == .claude else { return base } let config = self.providerConfig(for: provider) - return self.claudeCredentialRouting(account: account, config: config).isOAuth ? .oauth : base + let routing = self.claudeCredentialRouting(account: account, config: config) + + if base == .auto { + if routing.adminAPIKey != nil { return .api } + return routing.isOAuth ? .oauth : base + } + + guard base == .cli, account != nil else { + return base + } + + // Claude CLI usage is ambient to the active local CLI profile, so per-token-account + // CLI reads can be mislabeled as separate accounts. Use the selected account's + // routable credential instead. + switch routing { + case .adminAPIKey: + return .api + case .oauth: + return .oauth + case .webCookie: + return .web + case .none: + return base + } } func preferredSourceMode(for provider: UsageProvider) -> ProviderSourceMode { @@ -323,9 +387,19 @@ struct TokenAccountCLIContext { } private func codexAccountReconciler() -> DefaultCodexAccountReconciler { - DefaultCodexAccountReconciler( + let storeLoader: @Sendable () throws -> ManagedCodexAccountSet = if let managedCodexAccountStoreURL { + { + try FileManagedCodexAccountStore(fileURL: managedCodexAccountStoreURL).loadAccounts() + } + } else { + { + try FileManagedCodexAccountStore().loadAccounts() + } + } + return DefaultCodexAccountReconciler( + storeLoader: storeLoader, activeSource: self.providerConfig(for: .codex)?.codexActiveSource ?? .liveSystem, - baseEnvironment: ProcessInfo.processInfo.environment, + baseEnvironment: self.baseEnvironment, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) @@ -379,6 +453,15 @@ struct TokenAccountCLIContext { return MiniMaxAPIRegion(rawValue: raw) ?? .global } + private func resolveMoonshotRegion(_ config: ProviderConfig?) -> MoonshotRegion? { + guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return nil + } + return MoonshotRegion(rawValue: raw) ?? .international + } + private func resolveAlibabaCodingPlanRegion(_ config: ProviderConfig?) -> AlibabaCodingPlanAPIRegion { guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty diff --git a/Sources/CodexBarCore/AutoreleasePoolCompat.swift b/Sources/CodexBarCore/AutoreleasePoolCompat.swift new file mode 100644 index 000000000..7d0d32c4d --- /dev/null +++ b/Sources/CodexBarCore/AutoreleasePoolCompat.swift @@ -0,0 +1,8 @@ +import Foundation + +#if os(Linux) +@discardableResult +func autoreleasepool(_ work: () throws -> Result) rethrows -> Result { + try work() +} +#endif diff --git a/Sources/CodexBarCore/BrowserCookieImportOrder.swift b/Sources/CodexBarCore/BrowserCookieImportOrder.swift index 32549a13b..d1081f81f 100644 --- a/Sources/CodexBarCore/BrowserCookieImportOrder.swift +++ b/Sources/CodexBarCore/BrowserCookieImportOrder.swift @@ -44,7 +44,9 @@ extension Browser { .edge, .edgeBeta, .edgeCanary, .helium, .vivaldi, - .dia: + .dia, + .yandex, + .comet: return true @unknown default: return true diff --git a/Sources/CodexBarCore/CodexManagedAccounts.swift b/Sources/CodexBarCore/CodexManagedAccounts.swift index cdd104a43..f9190d22b 100644 --- a/Sources/CodexBarCore/CodexManagedAccounts.swift +++ b/Sources/CodexBarCore/CodexManagedAccounts.swift @@ -1,3 +1,4 @@ +import Crypto import Foundation public struct ManagedCodexAccount: Codable, Identifiable, Sendable { @@ -6,6 +7,7 @@ public struct ManagedCodexAccount: Codable, Identifiable, Sendable { public let providerAccountID: String? public let workspaceLabel: String? public let workspaceAccountID: String? + public let authFingerprint: String? public let managedHomePath: String public let createdAt: TimeInterval public let updatedAt: TimeInterval @@ -17,6 +19,7 @@ public struct ManagedCodexAccount: Codable, Identifiable, Sendable { providerAccountID: String? = nil, workspaceLabel: String? = nil, workspaceAccountID: String? = nil, + authFingerprint: String? = nil, managedHomePath: String, createdAt: TimeInterval, updatedAt: TimeInterval, @@ -27,6 +30,7 @@ public struct ManagedCodexAccount: Codable, Identifiable, Sendable { self.providerAccountID = Self.normalizeProviderAccountID(providerAccountID) self.workspaceLabel = Self.normalizeWorkspaceLabel(workspaceLabel) self.workspaceAccountID = Self.normalizeWorkspaceAccountID(workspaceAccountID) + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) self.managedHomePath = managedHomePath self.createdAt = createdAt self.updatedAt = updatedAt @@ -63,6 +67,7 @@ public struct ManagedCodexAccount: Codable, Identifiable, Sendable { providerAccountID: container.decodeIfPresent(String.self, forKey: .providerAccountID), workspaceLabel: container.decodeIfPresent(String.self, forKey: .workspaceLabel), workspaceAccountID: container.decodeIfPresent(String.self, forKey: .workspaceAccountID), + authFingerprint: container.decodeIfPresent(String.self, forKey: .authFingerprint), managedHomePath: container.decode(String.self, forKey: .managedHomePath), createdAt: container.decode(TimeInterval.self, forKey: .createdAt), updatedAt: container.decode(TimeInterval.self, forKey: .updatedAt), @@ -70,6 +75,42 @@ public struct ManagedCodexAccount: Codable, Identifiable, Sendable { } } +public enum CodexAuthFingerprint { + public static func authFileURL(homePath: String) -> URL { + URL(fileURLWithPath: homePath, isDirectory: true) + .appendingPathComponent("auth.json", isDirectory: false) + } + + public static func fingerprint(homePath: String, fileManager: FileManager = .default) -> String? { + let url = self.authFileURL(homePath: homePath) + guard fileManager.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url) + else { + return nil + } + return self.fingerprint(data: data) + } + + public static func fingerprint(env: [String: String], fileManager: FileManager = .default) -> String? { + self.fingerprint( + homePath: CodexHomeScope.ambientHomeURL(env: env, fileManager: fileManager).path, + fileManager: fileManager) + } + + public static func fingerprint(data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + public static func normalize(_ fingerprint: String?) -> String? { + guard let trimmed = fingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !trimmed.isEmpty + else { + return nil + } + return trimmed + } +} + public struct ManagedCodexAccountSet: Codable, Sendable { public let version: Int public let accounts: [ManagedCodexAccount] @@ -86,7 +127,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { public func account(email: String, providerAccountID: String? = nil) -> ManagedCodexAccount? { let normalizedEmail = ManagedCodexAccount.normalizeEmail(email) if let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID), - let exactMatch = self.accounts.first(where: { $0.providerAccountID == normalizedProviderAccountID }) + let exactMatch = self.accounts.first(where: { + $0.email == normalizedEmail && $0.providerAccountID == normalizedProviderAccountID + }) { return exactMatch } @@ -104,7 +147,7 @@ public struct ManagedCodexAccountSet: Codable, Sendable { private static func sanitizedAccounts(_ accounts: [ManagedCodexAccount]) -> [ManagedCodexAccount] { var seenIDs: Set = [] - var seenProviderAccountIDs: Set = [] + var seenProviderAccountKeys: Set = [] var seenLegacyEmails: Set = [] var sanitized: [ManagedCodexAccount] = [] sanitized.reserveCapacity(accounts.count) @@ -112,7 +155,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { for account in accounts { guard seenIDs.insert(account.id).inserted else { continue } if let providerAccountID = account.providerAccountID { - guard seenProviderAccountIDs.insert(providerAccountID).inserted else { continue } + guard seenProviderAccountKeys.insert("\(account.email)\u{0}\(providerAccountID)").inserted else { + continue + } } else { guard seenLegacyEmails.insert(account.email).inserted else { continue } } diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index c20759676..ab0526d66 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -78,12 +78,17 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var source: ProviderSourceMode? public var extrasEnabled: Bool? public var apiKey: String? + public var secretKey: String? public var cookieHeader: String? public var cookieSource: ProviderCookieSource? public var region: String? public var workspaceID: String? + public var enterpriseHost: String? public var tokenAccounts: ProviderTokenAccountData? public var codexActiveSource: CodexActiveSource? + public var quotaWarnings: QuotaWarningConfig? + public var kiloKnownOrganizations: [KiloOrganization]? + public var kiloEnabledOrganizationIDs: [String]? public init( id: UsageProvider, @@ -91,34 +96,60 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { source: ProviderSourceMode? = nil, extrasEnabled: Bool? = nil, apiKey: String? = nil, + secretKey: String? = nil, cookieHeader: String? = nil, cookieSource: ProviderCookieSource? = nil, region: String? = nil, workspaceID: String? = nil, + enterpriseHost: String? = nil, tokenAccounts: ProviderTokenAccountData? = nil, - codexActiveSource: CodexActiveSource? = nil) + codexActiveSource: CodexActiveSource? = nil, + quotaWarnings: QuotaWarningConfig? = nil, + kiloKnownOrganizations: [KiloOrganization]? = nil, + kiloEnabledOrganizationIDs: [String]? = nil) { self.id = id self.enabled = enabled self.source = source self.extrasEnabled = extrasEnabled self.apiKey = apiKey + self.secretKey = secretKey self.cookieHeader = cookieHeader self.cookieSource = cookieSource self.region = region self.workspaceID = workspaceID + self.enterpriseHost = enterpriseHost self.tokenAccounts = tokenAccounts self.codexActiveSource = codexActiveSource + self.quotaWarnings = quotaWarnings + self.kiloKnownOrganizations = kiloKnownOrganizations + self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs } public var sanitizedAPIKey: String? { Self.clean(self.apiKey) } + public var sanitizedSecretKey: String? { + Self.clean(self.secretKey) + } + public var sanitizedCookieHeader: String? { Self.clean(self.cookieHeader) } + public var sanitizedRegion: String? { + Self.clean(self.region) + } + + public var sanitizedWorkspaceID: String? { + Self.clean(self.workspaceID) + } + + public var sanitizedEnterpriseHost: String? { + Self.clean(self.enterpriseHost) + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -133,3 +164,109 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { return value.isEmpty ? nil : value } } + +public enum QuotaWarningWindow: String, Codable, Sendable, CaseIterable { + case session + case weekly + + public var displayName: String { + switch self { + case .session: + "session" + case .weekly: + "weekly" + } + } +} + +public struct QuotaWarningWindowConfig: Codable, Sendable, Equatable { + public var thresholds: [Int]? + public var enabled: Bool? + + public init(thresholds: [Int]? = nil, enabled: Bool? = nil) { + self.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + self.enabled = enabled + } + + public var hasOverride: Bool { + self.thresholds != nil || self.enabled != nil + } + + public func isEnabled(global: Bool) -> Bool { + self.enabled ?? (self.thresholds != nil ? true : global) + } +} + +public struct QuotaWarningConfig: Codable, Sendable, Equatable { + public var session: QuotaWarningWindowConfig? + public var weekly: QuotaWarningWindowConfig? + + public init( + session: QuotaWarningWindowConfig? = nil, + weekly: QuotaWarningWindowConfig? = nil) + { + self.session = session + self.weekly = weekly + } + + public func thresholds(for window: QuotaWarningWindow, global: [Int]) -> [Int] { + switch window { + case .session: + QuotaWarningThresholds.sanitized(self.session?.thresholds ?? global) + case .weekly: + QuotaWarningThresholds.sanitized(self.weekly?.thresholds ?? global) + } + } + + public func isEnabled(for window: QuotaWarningWindow, global: Bool) -> Bool { + switch window { + case .session: + self.session?.isEnabled(global: global) ?? global + case .weekly: + self.weekly?.isEnabled(global: global) ?? global + } + } + + public func hasOverride(for window: QuotaWarningWindow) -> Bool { + switch window { + case .session: + self.session?.hasOverride ?? false + case .weekly: + self.weekly?.hasOverride ?? false + } + } + + public var isEmpty: Bool { + self.session?.hasOverride != true && self.weekly?.hasOverride != true + } +} + +public enum QuotaWarningThresholds { + public static let defaults = [50, 20] + public static let allowedRange = 0...99 + + public static func sanitized(_ raw: [Int]) -> [Int] { + guard !raw.isEmpty else { return self.defaults } + + let unique = Set(raw.map(self.clamped)) + let sorted = unique.sorted(by: >) + return sorted.isEmpty ? self.defaults : sorted + } + + public static func active(_ raw: [Int]) -> [Int] { + self.sanitized(raw).filter { $0 > 0 } + } + + public static func resolved(upper: Int?, lower: Int?) -> [Int] { + guard upper != nil || lower != nil else { return self.defaults } + + let resolvedUpper = self.clamped(upper ?? self.defaults[0]) + let lowerDefault = resolvedUpper < self.defaults[1] ? 0 : self.defaults[1] + let resolvedLower = self.clamped(lower ?? lowerDefault) + return self.sanitized([resolvedUpper, resolvedLower]) + } + + public static func clamped(_ value: Int) -> Int { + min(max(value, self.allowedRange.lowerBound), self.allowedRange.upperBound) + } +} diff --git a/Sources/CodexBarCore/Config/CodexBarConfigStore.swift b/Sources/CodexBarCore/Config/CodexBarConfigStore.swift index e22c974a6..1662e17d9 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigStore.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigStore.swift @@ -18,6 +18,8 @@ public enum CodexBarConfigStoreError: LocalizedError { } public struct CodexBarConfigStore: @unchecked Sendable { + public static let pathEnvironmentKey = "CODEXBAR_CONFIG" + public let fileURL: URL private let fileManager: FileManager @@ -70,8 +72,17 @@ public struct CodexBarConfigStore: @unchecked Sendable { try self.fileManager.removeItem(at: self.fileURL) } - public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { - home + public static func defaultURL( + home: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[pathEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + let expanded = (override as NSString).expandingTildeInPath + return URL(fileURLWithPath: expanded) + } + return home .appendingPathComponent(".codexbar", isDirectory: true) .appendingPathComponent("config.json") } diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index fa6702cb3..a0dd11b83 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -125,56 +125,32 @@ public enum CodexBarConfigValidator { message: "cookieSource manual is set but cookieHeader is missing for \(provider.rawValue).")) } - if let region = entry.region, !region.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - switch provider { - case .minimax: - if MiniMaxAPIRegion(rawValue: region) == nil { - issues.append(CodexBarConfigIssue( - severity: .error, - provider: provider, - field: "region", - code: "invalid_region", - message: "Region \(region) is not a valid MiniMax region.")) - } - case .zai: - if ZaiAPIRegion(rawValue: region) == nil { - issues.append(CodexBarConfigIssue( - severity: .error, - provider: provider, - field: "region", - code: "invalid_region", - message: "Region \(region) is not a valid z.ai region.")) - } - case .alibaba: - if AlibabaCodingPlanAPIRegion(rawValue: region) == nil { - issues.append(CodexBarConfigIssue( - severity: .error, - provider: provider, - field: "region", - code: "invalid_region", - message: "Region \(region) is not a valid Alibaba Coding Plan region.")) - } - default: - issues.append(CodexBarConfigIssue( - severity: .warning, - provider: provider, - field: "region", - code: "region_unused", - message: "region is set but \(provider.rawValue) does not use regions.")) - } - } + self.validateSecretKey(entry, issues: &issues) + + self.validateRegion(entry, issues: &issues) if let workspaceID = entry.workspaceID, !workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - provider != .opencode, - provider != .opencodego + !self.providerSupportsWorkspaceID(provider) { issues.append(CodexBarConfigIssue( severity: .warning, provider: provider, field: "workspaceID", code: "workspace_unused", - message: "workspaceID is set but only opencode and opencodego support workspaceID.")) + message: "workspaceID is set but only opencode, opencodego, and deepgram support workspaceID.")) + } + + if let enterpriseHost = entry.enterpriseHost, + !enterpriseHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + provider != .copilot + { + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "enterpriseHost", + code: "enterprise_host_unused", + message: "enterpriseHost is set but only copilot supports enterpriseHost.")) } if let tokenAccounts = entry.tokenAccounts, !tokenAccounts.accounts.isEmpty, @@ -188,4 +164,94 @@ public enum CodexBarConfigValidator { message: "tokenAccounts are set but \(provider.rawValue) does not support token accounts.")) } } + + private static func validateSecretKey(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) { + guard let secretKey = entry.secretKey, + !secretKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + entry.id != .bedrock + else { + return + } + + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: entry.id, + field: "secretKey", + code: "secret_key_unused", + message: "secretKey is set but only bedrock uses secretKey.")) + } + + private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool { + switch provider { + case .opencode, .opencodego, .deepgram: + true + default: + false + } + } + + private static func validateRegion(_ entry: ProviderConfig, issues: inout [CodexBarConfigIssue]) { + let provider = entry.id + guard let region = entry.region?.trimmingCharacters(in: .whitespacesAndNewlines), + !region.isEmpty + else { + return + } + + switch provider { + case .minimax: + self.validateKnownRegion( + region, + provider: provider, + isValid: MiniMaxAPIRegion(rawValue: region) != nil, + displayName: "MiniMax", + issues: &issues) + case .zai: + self.validateKnownRegion( + region, + provider: provider, + isValid: ZaiAPIRegion(rawValue: region) != nil, + displayName: "z.ai", + issues: &issues) + case .alibaba: + self.validateKnownRegion( + region, + provider: provider, + isValid: AlibabaCodingPlanAPIRegion(rawValue: region) != nil, + displayName: "Alibaba Coding Plan", + issues: &issues) + case .moonshot: + self.validateKnownRegion( + region, + provider: provider, + isValid: MoonshotRegion(rawValue: region) != nil, + displayName: "Moonshot", + issues: &issues) + case .bedrock: + break + default: + issues.append(CodexBarConfigIssue( + severity: .warning, + provider: provider, + field: "region", + code: "region_unused", + message: "region is set but \(provider.rawValue) does not use regions.")) + } + } + + private static func validateKnownRegion( + _ region: String, + provider: UsageProvider, + isValid: Bool, + displayName: String, + issues: inout [CodexBarConfigIssue]) + { + guard !isValid else { return } + issues.append(CodexBarConfigIssue( + severity: .error, + provider: provider, + field: "region", + code: "invalid_region", + message: "Region \(region) is not a valid \(displayName) region.")) + } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 6620ae879..abc4a03c2 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -6,34 +6,136 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String] { + if provider == .bedrock { + return self.applyBedrockOverrides(base: base, config: config) + } + if provider == .deepgram { + return self.applyDeepgramOverrides(base: base, config: config) + } guard let apiKey = config?.sanitizedAPIKey, !apiKey.isEmpty else { return base } var env = base + if let key = self.directAPIKeyEnvironmentKey(for: provider) { + env[key] = apiKey + return env + } + switch provider { - case .zai: - env[ZaiSettingsReader.apiTokenKey] = apiKey case .copilot: env["COPILOT_API_TOKEN"] = apiKey - case .minimax: - env[MiniMaxAPISettingsReader.apiTokenKey] = apiKey - case .alibaba: - env[AlibabaCodingPlanSettingsReader.apiTokenKey] = apiKey - case .kilo: - env[KiloSettingsReader.apiTokenKey] = apiKey case .kimik2: if let key = KimiK2SettingsReader.apiKeyEnvironmentKeys.first { env[key] = apiKey } - case .synthetic: - env[SyntheticSettingsReader.apiKeyKey] = apiKey case .warp: if let key = WarpSettingsReader.apiKeyEnvironmentKeys.first { env[key] = apiKey } - case .openrouter: - env[OpenRouterSettingsReader.envKey] = apiKey + case .codebuff: + // Preserve a token already present in the process environment so that + // runtime/CI overrides win over a key saved in Settings (matches the + // precedence used by `ProviderTokenResolver.codebuffResolution`). + if CodebuffSettingsReader.apiKey(environment: base) == nil { + env[CodebuffSettingsReader.apiTokenKey] = apiKey + } + case .crof: + if CrofSettingsReader.apiKey(environment: base) == nil, + let key = CrofSettingsReader.apiKeyEnvironmentKeys.first + { + env[key] = apiKey + } + case .doubao: + if let key = DoubaoSettingsReader.apiKeyEnvironmentKeys.first { + env[key] = apiKey + } default: break } return env } + + public static func supportsAPIKeyOverride(for provider: UsageProvider) -> Bool { + if self.directAPIKeyEnvironmentKey(for: provider) != nil { return true } + switch provider { + case .copilot, .kimik2, .warp, .codebuff, .crof, .doubao: + return true + default: + return false + } + } + + private static func directAPIKeyEnvironmentKey(for provider: UsageProvider) -> String? { + switch provider { + case .openai: + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey + case .claude: + ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey + case .zai: + ZaiSettingsReader.apiTokenKey + case .minimax: + MiniMaxAPISettingsReader.apiTokenKey + case .alibaba: + AlibabaCodingPlanSettingsReader.apiTokenKey + case .kilo: + KiloSettingsReader.apiTokenKey + case .synthetic: + SyntheticSettingsReader.apiKeyKey + case .openrouter: + OpenRouterSettingsReader.envKey + case .elevenlabs: + ElevenLabsSettingsReader.apiKeyEnvironmentKey + case .moonshot: + MoonshotSettingsReader.apiKeyEnvironmentKeys.first + case .venice: + VeniceSettingsReader.apiKeyEnvironmentKey + case .deepgram: + DeepgramSettingsReader.apiKeyEnvironmentKey + default: + nil + } + } + + 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 + } + if let secretAccessKey = config.sanitizedSecretKey { + env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey + } + if let region = config.sanitizedRegion { + env[BedrockSettingsReader.regionKeys[0]] = region + } + return env + } + + private static func applyDeepgramOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + + var env = base + + if let apiKey = config.sanitizedAPIKey { + env[DeepgramSettingsReader.apiKeyEnvironmentKey] = apiKey + } + + if let projectID = config.sanitizedWorkspaceID { + env[DeepgramSettingsReader.projectIDEnvironmentKey] = projectID + } + + return env + } + + public static func applyProviderConfigOverrides( + base: [String: String], + provider: UsageProvider, + config: ProviderConfig?) -> [String: String] + { + self.applyAPIKeyOverride(base: base, provider: provider, config: config) + } } diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift index dae36ebf2..722c49d13 100644 --- a/Sources/CodexBarCore/CookieHeaderCache.swift +++ b/Sources/CodexBarCore/CookieHeaderCache.swift @@ -75,13 +75,69 @@ public enum CookieHeaderCache { self.log.debug("Cookie cache stored", metadata: ["provider": provider.rawValue, "source": sourceLabel]) } - public static func clear(provider: UsageProvider, scope: Scope? = nil) { + @discardableResult + public static func clear(provider: UsageProvider, scope: Scope? = nil) -> Int { let key = self.key(for: provider, scope: scope) - KeychainCacheStore.clear(key: key) - if scope == nil { - self.removeLegacyEntry(for: provider) + var cleared = KeychainCacheStore.clear(key: key) ? 1 : 0 + if scope == nil, self.removeLegacyEntry(for: provider) { + cleared += 1 } self.log.debug("Cookie cache cleared", metadata: ["provider": provider.rawValue]) + return cleared + } + + /// Clears all cookie cache scopes for one provider, including managed Codex account scopes. + /// Returns the number of keychain or legacy-file entries removed. + @discardableResult + public static func clearAllScopes(provider: UsageProvider) -> Int { + let keys = self.cookieKeys(for: provider) + var cleared = 0 + for key in keys where KeychainCacheStore.clear(key: key) { + cleared += 1 + } + if self.removeLegacyEntry(for: provider) { + cleared += 1 + } + self.log.debug("Cookie cache clearAllScopes completed", metadata: [ + "provider": provider.rawValue, + "cleared": "\(cleared)", + ]) + return cleared + } + + /// Clears cookie caches for all providers, including corrupt/invalid entries. + /// Returns the number of keychain or legacy-file entries removed. + @discardableResult + public static func clearAll() -> Int { + var cleared = 0 + for key in KeychainCacheStore.keys(category: "cookie") where KeychainCacheStore.clear(key: key) { + cleared += 1 + } + for provider in UsageProvider.allCases where self.removeLegacyEntry(for: provider) { + cleared += 1 + } + self.log.debug("Cookie cache clearAll completed", metadata: ["cleared": "\(cleared)"]) + return cleared + } + + private static func cookieKeys(for provider: UsageProvider) -> [KeychainCacheStore.Key] { + let exactIdentifier = provider.rawValue + let scopedPrefix = "\(provider.rawValue)." + var seen = Set() + var keys: [KeychainCacheStore.Key] = [] + for key in KeychainCacheStore.keys(category: "cookie") { + guard key.identifier == exactIdentifier || key.identifier.hasPrefix(scopedPrefix) else { + continue + } + if seen.insert(key).inserted { + keys.append(key) + } + } + let global = self.key(for: provider, scope: nil) + if seen.insert(global).inserted { + keys.append(global) + } + return keys } static func load(from url: URL) -> Entry? { @@ -108,21 +164,47 @@ public enum CookieHeaderCache { self.legacyBaseURLOverride = url } - private static func loadLegacyEntry(for provider: UsageProvider) -> Entry? { - self.load(from: self.legacyURL(for: provider)) + static func hasLegacyEntryForTesting(provider: UsageProvider) -> Bool { + self.loadLegacyEntry(for: provider) != nil + } + + static func legacyURLForTesting(provider: UsageProvider) -> URL { + self.legacyURL(for: provider) } - private static func removeLegacyEntry(for provider: UsageProvider) { + private static func hasKeychainEntry(provider: UsageProvider, scope: Scope?) -> Bool { + let key = self.key(for: provider, scope: scope) + switch KeychainCacheStore.load(key: key, as: Entry.self) { + case .found, .invalid: + return true + case .missing, .temporarilyUnavailable: + return false + } + } + + static func hasKeychainEntryForTesting(provider: UsageProvider, scope: Scope? = nil) -> Bool { + self.hasKeychainEntry(provider: provider, scope: scope) + } + + @discardableResult + private static func removeLegacyEntry(for provider: UsageProvider) -> Bool { let url = self.legacyURL(for: provider) + let existed = FileManager.default.fileExists(atPath: url.path) do { try FileManager.default.removeItem(at: url) + return existed } catch { if (error as NSError).code != NSFileNoSuchFileError { Self.log.error("Failed to remove cookie cache (\(provider.rawValue)): \(error)") } + return false } } + private static func loadLegacyEntry(for provider: UsageProvider) -> Entry? { + self.load(from: self.legacyURL(for: provider)) + } + private static func legacyURL(for provider: UsageProvider) -> URL { if let override = self.legacyBaseURLOverride { return override.appendingPathComponent("\(provider.rawValue)-cookie.json") diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index fef52b64c..d845624b9 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -22,6 +22,14 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let percentRemaining: Double public let quotaId: String public let hasPercentRemaining: Bool + public var usedPercent: Double { + max(0, 100 - self.percentRemaining) + } + + public var overQuotaUsedPercent: Double? { + self.usedPercent > 100 ? self.usedPercent : nil + } + public var isPlaceholder: Bool { self.entitlement == 0 && self.remaining == 0 && self.percentRemaining == 0 && self.quotaId.isEmpty } @@ -55,14 +63,14 @@ public struct CopilotUsageResponse: Sendable, Decodable { self.remaining = decodedRemaining ?? 0 let decodedPercent = Self.decodeNumberIfPresent(container: container, key: .percentRemaining) if let decodedPercent { - self.percentRemaining = max(0, min(100, decodedPercent)) + self.percentRemaining = decodedPercent self.hasPercentRemaining = true } else if let entitlement = decodedEntitlement, entitlement > 0, let remaining = decodedRemaining { let derived = (remaining / entitlement) * 100 - self.percentRemaining = max(0, min(100, derived)) + self.percentRemaining = derived self.hasPercentRemaining = true } else { // Without percent_remaining and both inputs for derivation, the percent is unknown. diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 8960e35a9..441f88d7f 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -22,27 +22,33 @@ public struct CostUsageFetcher: Sendable { public func loadTokenSnapshot( provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), forceRefresh: Bool = false, - allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, + environment: environment, now: now, forceRefresh: forceRefresh, - allowVertexClaudeFallback: allowVertexClaudeFallback) + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath) } static func loadTokenSnapshot( provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { throw CostUsageError.unsupportedProvider(provider) } @@ -50,7 +56,26 @@ public struct CostUsageFetcher: Sendable { // Rolling window: last 30 days (inclusive). Use -29 for inclusive boundaries. let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now + if provider == .bedrock { + let daily = try await Self.loadBedrockDailyReport( + environment: environment, + since: since, + until: until) + return Self.tokenSnapshot(from: daily, now: now) + } + var options = overrideScannerOptions ?? CostUsageScanner.Options() + if provider == .codex, + let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + if provider == .codex || provider == .claude { + await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: options.cacheRoot) + } + if provider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly } else if provider == .claude { @@ -58,7 +83,6 @@ public struct CostUsageFetcher: Sendable { } if forceRefresh { options.refreshMinIntervalSeconds = 0 - options.forceRescan = true } var daily = CostUsageScanner.loadDailyReport( provider: provider, @@ -89,7 +113,6 @@ public struct CostUsageFetcher: Sendable { } if forceRefresh { piOptions.refreshMinIntervalSeconds = 0 - piOptions.forceRescan = true } let piReport = PiSessionCostScanner.loadDailyReport( provider: provider, @@ -103,6 +126,27 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now) } + 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)) + return try await BedrockUsageFetcher.fetchDailyReport( + credentials: credentials, + since: since, + until: until, + environment: environment) + } + static func tokenSnapshot(from daily: CostUsageDailyReport, now: Date) -> CostUsageTokenSnapshot { // Pick the most recent day; break ties by cost/tokens to keep a stable "session" row. let currentDay = daily.data.max { lhs, rhs in diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 34c8bf38b..4febdad9a 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -36,6 +36,10 @@ public struct SubprocessResult: Sendable { public enum SubprocessRunner { private static let log = CodexBarLog.logger(LogCategories.subprocess) + private static let timeoutQueue = DispatchQueue( + label: "com.steipete.codexbar.subprocess.timeout", + qos: .userInitiated, + attributes: .concurrent) /// Thread-safe flag for communicating between concurrent tasks (e.g. timeout → caller). private final class KillFlag: @unchecked Sendable { @@ -51,6 +55,26 @@ public enum SubprocessRunner { } } + private final class TimeoutTimer: @unchecked Sendable { + private let timer: any DispatchSourceTimer + + init(timer: any DispatchSourceTimer) { + self.timer = timer + } + + func cancel() { + self.timer.cancel() + } + } + + private static func timeoutInterval(_ timeout: TimeInterval) -> DispatchTimeInterval { + guard timeout.isFinite else { + return .seconds(Int.max) + } + let nanoseconds = max(0, min(timeout * 1_000_000_000, Double(Int.max))) + return .nanoseconds(Int(nanoseconds)) + } + private final class ProcessTermination: @unchecked Sendable { private let lock = NSLock() private var status: Int32? @@ -188,35 +212,35 @@ public enum SubprocessRunner { } let killedByTimeout = KillFlag() + let timeoutTimer = DispatchSource.makeTimerSource(queue: self.timeoutQueue) + timeoutTimer.schedule(deadline: .now() + self.timeoutInterval(timeout)) + timeoutTimer.setEventHandler { + guard process.isRunning else { return } + killedByTimeout.set() + self.terminateProcess(process, processGroup: processGroup) + } + timeoutTimer.resume() + let timeoutTimerBox = TimeoutTimer(timer: timeoutTimer) do { - let exitCode = try await withThrowingTaskGroup(of: Int32.self) { group in - group.addTask { await exitCodeTask.value } - group.addTask { - try await Task.sleep(for: .seconds(timeout)) - // Kill the process BEFORE throwing so the exit-code task can complete - // and withThrowingTaskGroup can exit promptly. Only throw if we - // actually killed the process; if it already exited, let the exit - // code win the race naturally. - guard self.terminateProcess(process, processGroup: processGroup) else { - return await exitCodeTask.value - } - killedByTimeout.set() - throw SubprocessRunnerError.timedOut(label) - } - let code = try await group.next()! - group.cancelAll() + let exitCode = try await withTaskCancellationHandler { + try Task.checkCancellation() + let code = await exitCodeTask.value + try Task.checkCancellation() return code + } onCancel: { + timeoutTimerBox.cancel() + self.terminateProcess(process, processGroup: processGroup) } + timeoutTimerBox.cancel() - // Race guard: our timeout task killed the process, but the exit code - // arrived at group.next() before the .timedOut throw. Use the explicit - // flag instead of wall-clock heuristics to avoid misclassifying processes - // that crash or are killed externally. + let duration = Date().timeIntervalSince(start) + // Race guard: the timeout timer may kill the process just before the + // exit code arrives. Key off the explicit kill flag so a completed + // process is not misclassified when the awaiting task resumes late. if killedByTimeout.isSet { - let duration = Date().timeIntervalSince(start) self.log.warning( - "Subprocess timed out (race)", + "Subprocess timed out", metadata: [ "label": label, "binary": binaryName, @@ -245,7 +269,6 @@ public enum SubprocessRunner { throw SubprocessRunnerError.nonZeroExit(code: exitCode, stderr: stderr) } - let duration = Date().timeIntervalSince(start) self.log.debug( "Subprocess exit", metadata: [ @@ -264,7 +287,7 @@ public enum SubprocessRunner { "binary": binaryName, "duration_ms": "\(Int(duration * 1000))", ]) - // Safety net: ensure the process is dead (may already be killed by timeout task). + // Safety net: ensure the process is dead (may already be killed by timeout timer). self.terminateProcess(process, processGroup: processGroup) exitCodeTask.cancel() stdoutTask.cancel() diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 260435ff5..9c335d76c 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -134,19 +134,7 @@ public enum KeychainAccessPreflight { } #endif guard !KeychainAccessGate.isDisabled else { return .notFound } - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecMatchLimit as String: kSecMatchLimitOne, - // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because - // some macOS configurations still surface legacy prompts more aggressively when reading secret data, - // even with a non-interactive LAContext. - kSecReturnAttributes as String: true, - ] - KeychainNoUIQuery.apply(to: &query) - if let account { - query[kSecAttrAccount as String] = account - } + let query = self.makeGenericPasswordPreflightQuery(service: service, account: account) var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) @@ -174,4 +162,23 @@ public enum KeychainAccessPreflight { return .notFound #endif } + + #if os(macOS) + static func makeGenericPasswordPreflightQuery(service: String, account: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because + // some macOS configurations have been observed to show the legacy keychain prompt unless the query + // is strictly non-interactive. + kSecReturnAttributes as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + if let account { + query[kSecAttrAccount as String] = account + } + return query + } + #endif } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index ad5b9db37..405199c06 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -1,5 +1,6 @@ import Foundation #if os(macOS) +import Darwin import Security #endif @@ -40,6 +41,7 @@ public enum KeychainCacheStore { } private nonisolated(unsafe) static var testStore: [TestStoreKey: Data]? + private nonisolated(unsafe) static var implicitTestStore: [TestStoreKey: Data] = [:] private nonisolated(unsafe) static var testStoreRefCount = 0 public static func load( @@ -54,6 +56,7 @@ public enum KeychainCacheStore { if let testResult = loadFromTestStore(key: key, as: type) { return testResult } + guard self.canUseRealKeychain else { return .missing } #if os(macOS) var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -90,6 +93,7 @@ public enum KeychainCacheStore { if self.storeInTestStore(key: key, entry: entry) { return } + guard self.canUseRealKeychain else { return } #if os(macOS) let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { @@ -102,11 +106,10 @@ public enum KeychainCacheStore { kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] - let updateAttrs: [String: Any] = [ - kSecValueData as String: data, - ] - let updateStatus = SecItemUpdate(query as CFDictionary, updateAttrs as CFDictionary) + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary) if updateStatus == errSecSuccess { return } @@ -119,6 +122,9 @@ public enum KeychainCacheStore { addQuery[kSecValueData as String] = data addQuery[kSecAttrLabel as String] = self.cacheLabel addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + if let access = self.cacheAccessControl() { + addQuery[kSecAttrAccess as String] = access + } let addStatus = SecItemAdd(addQuery as CFDictionary, nil) if addStatus != errSecSuccess { @@ -127,10 +133,12 @@ public enum KeychainCacheStore { #endif } - public static func clear(key: Key) { - if self.clearTestStore(key: key) { - return + @discardableResult + public static func clear(key: Key) -> Bool { + if let removed = self.clearTestStore(key: key) { + return removed } + guard self.canUseRealKeychain else { return false } #if os(macOS) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -138,10 +146,51 @@ public enum KeychainCacheStore { kSecAttrAccount as String: key.account, ] let status = SecItemDelete(query as CFDictionary) - if status != errSecSuccess, status != errSecItemNotFound { + if status == errSecSuccess { + return true + } + if status != errSecItemNotFound { self.log.error("Keychain cache delete failed (\(key.account)): \(status)") } #endif + return false + } + + public static func keys(category: String) -> [Key] { + if let keys = self.keysFromTestStore(category: category) { + return keys + } + guard self.canUseRealKeychain else { return [] } + #if os(macOS) + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.serviceName, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecReturnAttributes as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let rows = result as? [[String: Any]] else { return [] } + return rows.compactMap { row in + guard let account = row[kSecAttrAccount as String] as? String else { return nil } + return self.key(fromAccount: account, category: category) + } + case errSecItemNotFound: + return [] + case errSecInteractionNotAllowed: + self.log.info("Keychain cache keys temporarily unavailable (\(category))") + return [] + default: + self.log.error("Keychain cache key listing failed (\(category)): \(status)") + return [] + } + #else + return [] + #endif } static func setServiceOverrideForTesting(_ service: String?) { @@ -179,6 +228,10 @@ public enum KeychainCacheStore { self.serviceOverride } + static var canUseRealKeychainForTesting: Bool { + self.canUseRealKeychain + } + #if DEBUG && os(macOS) public static func withLoadFailureStatusOverrideForTesting( _ status: OSStatus?, @@ -210,6 +263,27 @@ public enum KeychainCacheStore { serviceOverride ?? self.globalServiceOverride ?? self.cacheService } + private static var canUseRealKeychain: Bool { + !KeychainAccessGate.isDisabled + } + + #if DEBUG + private static var shouldUseImplicitTestStore: Bool { + self.isRunningUnderTests && !self.canUseRealKeychain + } + + private static var isRunningUnderTests: Bool { + let processName = ProcessInfo.processInfo.processName + return processName == "swiftpm-testing-helper" + || processName.hasSuffix("PackageTests") + || ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + #else + private static var shouldUseImplicitTestStore: Bool { + false + } + #endif + private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -239,6 +313,105 @@ public enum KeychainCacheStore { return .invalid } } + + static func trustedApplicationPathsForCacheAccess( + bundleURL: URL = Bundle.main.bundleURL, + executableURL: URL? = Bundle.main.executableURL, + fileExists: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) }) -> [String] + { + var paths: [String] = [] + func append(_ path: String) { + guard !path.isEmpty, fileExists(path), !paths.contains(path) else { return } + paths.append(path) + } + + let appBundle = self.appBundleURL(containing: bundleURL) + ?? executableURL.flatMap(self.appBundleURL(containing:)) + if let appBundle { + append(appBundle.path) + append(appBundle.appendingPathComponent("Contents/Helpers/CodexBarCLI").path) + } + if let executableURL { + append(executableURL.path) + } + return paths + } + + private static func appBundleURL(containing url: URL) -> URL? { + var current = url.standardizedFileURL + while current.path != "/" { + if current.pathExtension == "app" { + return current + } + current.deleteLastPathComponent() + } + return nil + } + + private static func cacheAccessControl() -> SecAccess? { + let trustedPaths = self.trustedApplicationPathsForCacheAccess() + guard !trustedPaths.isEmpty else { return nil } + + var trustedApplications: [SecTrustedApplication] = [] + for path in trustedPaths { + let (status, application) = self.createTrustedApplication(path: path) + if status == errSecSuccess, let application { + trustedApplications.append(application) + } else { + self.log.error("Keychain cache trusted app creation failed (\(path)): \(status)") + } + } + guard !trustedApplications.isEmpty else { return nil } + + let (status, access) = self.createAccessControl(trustedApplications: trustedApplications) + if status != errSecSuccess { + self.log.error("Keychain cache access control creation failed: \(status)") + return nil + } + return access + } + + private typealias SecTrustedApplicationCreateFromPathFunction = @convention(c) ( + UnsafePointer?, + UnsafeMutablePointer?) -> OSStatus + private typealias SecAccessCreateFunction = @convention(c) ( + CFString, + CFArray, + UnsafeMutablePointer?) -> OSStatus + + private static func createTrustedApplication(path: String) -> (OSStatus, SecTrustedApplication?) { + guard let symbol = self.securitySymbol(named: "SecTrustedApplicationCreateFromPath") else { + return (errSecInternalComponent, nil) + } + let function = unsafeBitCast(symbol, to: SecTrustedApplicationCreateFromPathFunction.self) + var application: SecTrustedApplication? + let status = path.withCString { cPath in + function(cPath, &application) + } + return (status, application) + } + + private static func createAccessControl(trustedApplications: [SecTrustedApplication]) -> (OSStatus, SecAccess?) { + guard let symbol = self.securitySymbol(named: "SecAccessCreate") else { + return (errSecInternalComponent, nil) + } + let function = unsafeBitCast(symbol, to: SecAccessCreateFunction.self) + var access: SecAccess? + let status = function(self.cacheLabel as CFString, trustedApplications as CFArray, &access) + return (status, access) + } + + private nonisolated(unsafe) static let securityFrameworkHandle: UnsafeMutableRawPointer? = { + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + return dlopen(securityPath, RTLD_NOW) + }() + + private static func securitySymbol(named name: String) -> UnsafeMutableRawPointer? { + // Resolve deprecated SecKeychain ACL helpers at runtime so release builds stay warning-free + // while still granting the app bundle and bundled CLI prompt-free access to cache entries. + guard let securityFrameworkHandle else { return nil } + return dlsym(securityFrameworkHandle, name) + } #endif private static func loadFromTestStore( @@ -247,7 +420,8 @@ public enum KeychainCacheStore { { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } - guard let store = self.testStore else { return nil } + guard let store = self.testStore ?? (self.shouldUseImplicitTestStore ? self.implicitTestStore : nil) + else { return nil } let testKey = TestStoreKey(service: self.serviceName, account: key.account) guard let data = store[testKey] else { return .missing } let decoder = Self.makeDecoder() @@ -260,23 +434,53 @@ public enum KeychainCacheStore { private static func storeInTestStore(key: Key, entry: some Codable) -> Bool { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } - guard var store = self.testStore else { return false } let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { return true } let testKey = TestStoreKey(service: self.serviceName, account: key.account) - store[testKey] = data - self.testStore = store - return true + if var store = self.testStore { + store[testKey] = data + self.testStore = store + return true + } + if self.shouldUseImplicitTestStore { + self.implicitTestStore[testKey] = data + return true + } + return false } - private static func clearTestStore(key: Key) -> Bool { + private static func clearTestStore(key: Key) -> Bool? { self.testStoreLock.lock() defer { self.testStoreLock.unlock() } - guard var store = self.testStore else { return false } let testKey = TestStoreKey(service: self.serviceName, account: key.account) - store.removeValue(forKey: testKey) - self.testStore = store - return true + if var store = self.testStore { + let removed = store.removeValue(forKey: testKey) != nil + self.testStore = store + return removed + } + if self.shouldUseImplicitTestStore { + return self.implicitTestStore.removeValue(forKey: testKey) != nil + } + return nil + } + + private static func keysFromTestStore(category: String) -> [Key]? { + self.testStoreLock.lock() + defer { self.testStoreLock.unlock() } + guard let store = self.testStore ?? (self.shouldUseImplicitTestStore ? self.implicitTestStore : nil) + else { return nil } + return store.keys + .filter { $0.service == self.serviceName } + .compactMap { self.key(fromAccount: $0.account, category: category) } + .sorted { $0.identifier < $1.identifier } + } + + private static func key(fromAccount account: String, category: String) -> Key? { + let prefix = "\(category)." + guard account.hasPrefix(prefix) else { return nil } + let identifier = String(account.dropFirst(prefix.count)) + guard !identifier.isEmpty else { return nil } + return Key(category: category, identifier: identifier) } } diff --git a/Sources/CodexBarCore/KeychainNoUIQuery.swift b/Sources/CodexBarCore/KeychainNoUIQuery.swift index debfa66e4..58df3e085 100644 --- a/Sources/CodexBarCore/KeychainNoUIQuery.swift +++ b/Sources/CodexBarCore/KeychainNoUIQuery.swift @@ -1,14 +1,41 @@ import Foundation #if os(macOS) +import Darwin import LocalAuthentication import Security enum KeychainNoUIQuery { + private static let uiFailPolicy = KeychainNoUIQuery.resolveUIFailPolicy() + static func apply(to query: inout [String: Any]) { let context = LAContext() context.interactionNotAllowed = true query[kSecUseAuthenticationContext as String] = context + + // Keep explicit UI-fail policy for legacy keychain behavior on macOS where + // `interactionNotAllowed` alone can still surface Allow/Deny prompts. + query[kSecUseAuthenticationUI as String] = self.uiFailPolicy as CFString + } + + static func uiFailPolicyForTesting() -> String { + self.uiFailPolicy + } + + private static func resolveUIFailPolicy() -> String { + // Resolve the Security symbol at runtime to preserve the true constant value + // without directly referencing deprecated API at compile time. + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" } } #endif diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index fba33c0ce..fad72ea51 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -7,11 +7,14 @@ public enum LogCategories { public static let auggieCLI = "auggie-cli" public static let augment = "augment" public static let augmentKeepalive = "augment-keepalive" + public static let bedrockUsage = "bedrock-usage" public static let browserCookieGate = "browser-cookie-gate" public static let claudeCLI = "claude-cli" public static let claudeProbe = "claude-probe" public static let claudeUsage = "claude-usage" public static let codexRPC = "codex-rpc" + public static let commandcodeCookie = "commandcode-cookie" + public static let commandcodeUsage = "commandcode-usage" public static let configMigration = "config-migration" public static let configStore = "config-store" public static let confetti = "confetti" @@ -20,7 +23,13 @@ public enum LogCategories { public static let copilotTokenStore = "copilot-token-store" public static let creditsPurchase = "creditsPurchase" public static let cursorLogin = "cursor-login" + public static let deepSeekSettings = "deepseek-settings" + public static let deepSeekUsage = "deepseek-usage" + public static let deepgramUsage = "deepgram-usage" + public static let doubaoUsage = "doubao-usage" + public static let elevenLabsUsage = "elevenlabs-usage" public static let geminiProbe = "gemini-probe" + public static let grok = "grok" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" public static let keychainPreflight = "keychain-preflight" @@ -35,11 +44,15 @@ public enum LogCategories { public static let launchAtLogin = "launch-at-login" public static let login = "login" public static let logging = "logging" + public static let manusAPI = "manus-api" + public static let manusCookie = "manus-cookie" + public static let manusWeb = "manus-web" public static let minimaxAPITokenStore = "minimax-api-token-store" public static let minimaxCookie = "minimax-cookie" public static let minimaxCookieStore = "minimax-cookie-store" public static let minimaxUsage = "minimax-usage" public static let minimaxWeb = "minimax-web" + public static let moonshotUsage = "moonshot-usage" public static let notifications = "notifications" public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" @@ -52,6 +65,7 @@ public enum LogCategories { public static let perplexityWeb = "perplexity-web" public static let providerDetection = "provider-detection" public static let providers = "providers" + public static let quotaWarningNotifications = "quotaWarningNotifications" public static let sessionQuota = "sessionQuota" public static let sessionQuotaNotifications = "sessionQuotaNotifications" public static let settings = "settings" @@ -62,10 +76,12 @@ public enum LogCategories { public static let tokenAccounts = "token-accounts" public static let tokenCost = "token-cost" public static let ttyRunner = "tty-runner" + public static let veniceUsage = "venice-usage" public static let vertexAIFetcher = "vertexai-fetcher" public static let warpUsage = "warp-usage" public static let webkitTeardown = "webkit-teardown" public static let zaiSettings = "zai-settings" public static let zaiTokenStore = "zai-token-store" public static let zaiUsage = "zai-usage" + public static let stepfunUsage = "stepfun-usage" } diff --git a/Sources/CodexBarCore/ManagedCodexAccountStore.swift b/Sources/CodexBarCore/ManagedCodexAccountStore.swift index 887fb4316..060b303f4 100644 --- a/Sources/CodexBarCore/ManagedCodexAccountStore.swift +++ b/Sources/CodexBarCore/ManagedCodexAccountStore.swift @@ -11,7 +11,7 @@ public protocol ManagedCodexAccountStoring: Sendable { } public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @unchecked Sendable { - public static let currentVersion = 2 + public static let currentVersion = 3 private let fileURL: URL private let fileManager: FileManager @@ -35,7 +35,7 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck if accounts.version == Self.currentVersion { return ManagedCodexAccountSet(version: Self.currentVersion, accounts: accounts.accounts) } - return self.migrateVersion1Accounts(accounts) + return self.migrateLegacyAccounts(accounts) } public func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { @@ -71,15 +71,18 @@ public struct FileManagedCodexAccountStore: ManagedCodexAccountStoring, @uncheck ManagedCodexAccountSet(version: self.currentVersion, accounts: []) } - private func migrateVersion1Accounts(_ accounts: ManagedCodexAccountSet) -> ManagedCodexAccountSet { + private func migrateLegacyAccounts(_ accounts: ManagedCodexAccountSet) -> ManagedCodexAccountSet { let migratedAccounts = accounts.accounts.map { account in - let hydratedProviderAccountID = self.hydrateProviderAccountID(for: account) + let hydratedProviderAccountID = account.providerAccountID ?? self.hydrateProviderAccountID(for: account) return ManagedCodexAccount( id: account.id, email: account.email, providerAccountID: hydratedProviderAccountID, workspaceLabel: account.workspaceLabel, workspaceAccountID: account.workspaceAccountID, + authFingerprint: account.authFingerprint ?? CodexAuthFingerprint.fingerprint( + homePath: account.managedHomePath, + fileManager: self.fileManager), managedHomePath: account.managedHomePath, createdAt: account.createdAt, updatedAt: account.updatedAt, diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 702f1582a..7d776a240 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -36,7 +36,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.codeReviewLimit = codeReviewLimit self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown - self.usageBreakdown = usageBreakdown + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: usageBreakdown) self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit @@ -72,9 +72,11 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { [OpenAIDashboardDailyBreakdown].self, forKey: .dailyBreakdown) ?? Self.makeDailyBreakdown(from: self.creditEvents, maxDays: 30) - self.usageBreakdown = try container.decodeIfPresent( + let decodedUsageBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .usageBreakdown) ?? [] + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: decodedUsageBreakdown) 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) @@ -145,6 +147,33 @@ public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { self.services = services self.totalCreditsUsed = totalCreditsUsed } + + public static func isSkillUsageService(_ service: String) -> Bool { + service + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("skillusage:") + } + + public static func removingSkillUsageServices( + from breakdown: [OpenAIDashboardDailyBreakdown]) + -> [OpenAIDashboardDailyBreakdown] + { + breakdown.compactMap { day in + guard !day.services.isEmpty else { + return day.totalCreditsUsed > 0 ? day : nil + } + + let services = day.services.filter { !self.isSkillUsageService($0.service) } + guard !services.isEmpty else { return nil } + + let total = services.reduce(0) { $0 + $1.creditsUsed } + return OpenAIDashboardDailyBreakdown( + day: day.day, + services: services, + totalCreditsUsed: total) + } + } } public struct OpenAIDashboardServiceUsage: Codable, Equatable, Sendable { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 71ab76628..a056b99bb 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -20,6 +20,8 @@ public struct OpenAIDashboardFetcher { } private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! + private nonisolated static let dashboardAcceptLanguage = "en-US,en;q=0.9" + private nonisolated static let dashboardUsageAPIURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! public init() {} @@ -43,6 +45,7 @@ public struct OpenAIDashboardFetcher { } private struct DashboardSnapshotComponents { + let signedInEmail: String? let scrape: ScrapeResult let codeReview: Double? let codeReviewLimit: RateWindow? @@ -54,11 +57,25 @@ public struct OpenAIDashboardFetcher { let accountPlan: String? } + private struct DashboardScrapeData { + let signedInEmail: String? + let codeReview: Double? + let codeReviewLimit: RateWindow? + let events: [CreditEvent] + let breakdown: [OpenAIDashboardDailyBreakdown] + let usageBreakdown: [OpenAIDashboardDailyBreakdown] + let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let creditsRemaining: Double? + let accountPlan: String? + let hasDashboardPageSignal: Bool + let hasReturnableData: Bool + } + private nonisolated static func makeDashboardSnapshot(_ components: DashboardSnapshotComponents) -> OpenAIDashboardSnapshot { OpenAIDashboardSnapshot( - signedInEmail: components.scrape.signedInEmail, + signedInEmail: components.signedInEmail, codeReviewRemainingPercent: components.codeReview, codeReviewLimit: components.codeReviewLimit, creditEvents: components.events, @@ -72,6 +89,67 @@ public struct OpenAIDashboardFetcher { updatedAt: Date()) } + private static func parseDashboardScrape( + _ scrape: ScrapeResult, + apiData: DashboardAPIData?, + verifiedSignedInEmail: String?) -> DashboardScrapeData + { + let bodyText = scrape.bodyText ?? "" + let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) + let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) + let usageBreakdown = scrape.usageBreakdown + let parsedRateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let rateLimits = ( + primary: apiData?.primaryLimit ?? parsedRateLimits.primary, + secondary: apiData?.secondaryLimit ?? parsedRateLimits.secondary) + let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText) + let parsedCreditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) + let creditsRemaining = apiData?.creditsRemaining ?? parsedCreditsRemaining + let parsedAccountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) + let accountPlan = parsedAccountPlan ?? apiData?.accountPlan + let hasParsedUsageLimits = parsedRateLimits.primary != nil || parsedRateLimits.secondary != nil + let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil + let hasDashboardPageData = self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasParsedUsageLimits, + creditsRemaining: parsedCreditsRemaining) + let hasReturnableData = self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasUsageLimits, + creditsRemaining: creditsRemaining) + + return DashboardScrapeData( + signedInEmail: self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail), + codeReview: codeReview, + codeReviewLimit: codeReviewLimit, + events: events, + breakdown: breakdown, + usageBreakdown: usageBreakdown, + rateLimits: rateLimits, + creditsRemaining: creditsRemaining, + accountPlan: accountPlan, + hasDashboardPageSignal: self.hasAnyDashboardSignal( + hasReturnableData: hasDashboardPageData, + creditsHeaderPresent: scrape.creditsHeaderPresent), + hasReturnableData: hasReturnableData) + } + + struct DashboardAPIData { + let primaryLimit: RateWindow? + let secondaryLimit: RateWindow? + let creditsRemaining: Double? + let accountPlan: String? + + var hasUsageData: Bool { + self.primaryLimit != nil || self.secondaryLimit != nil || self.creditsRemaining != nil + } + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -134,6 +212,12 @@ public struct OpenAIDashboardFetcher { 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 + let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, @@ -149,7 +233,9 @@ public struct OpenAIDashboardFetcher { var codeReviewFirstSeenAt: Date? var anyDashboardSignalAt: Date? var creditsHeaderVisibleAt: Date? + var usageBreakdownErrorFirstSeenAt: Date? var lastUsageBreakdownDebug: String? + var lastUsageBreakdownError: String? var lastCreditsPurchaseURL: String? while Date() < deadline { let scrape = try await self.scrape(webView: webView) @@ -176,41 +262,25 @@ public struct OpenAIDashboardFetcher { // 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(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } - if scrape.loginRequired { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.loginRequired - } + try Self.throwIfBlockingScrapeState(scrape, debugDumpHTML: debugDumpHTML, logger: log) - if scrape.cloudflareInterstitial { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") - } - - let bodyText = scrape.bodyText ?? "" - let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) - let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows) - let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) - let usageBreakdown = scrape.usageBreakdown - let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) - let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText) - let creditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) - let accountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) - let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil + let dashboardData = Self.parseDashboardScrape( + scrape, + apiData: apiData, + verifiedSignedInEmail: verifiedSignedInEmail) + let codeReview = dashboardData.codeReview + let events = dashboardData.events + let usageBreakdown = dashboardData.usageBreakdown + let hasDashboardPageSignal = dashboardData.hasDashboardPageSignal + let hasReturnableData = dashboardData.hasReturnableData if codeReview != nil, codeReviewFirstSeenAt == nil { codeReviewFirstSeenAt = Date() } - if anyDashboardSignalAt == nil, - codeReview != nil || !usageBreakdown.isEmpty || scrape.creditsHeaderPresent || - hasUsageLimits || creditsRemaining != nil - { + if anyDashboardSignalAt == nil, hasDashboardPageSignal { anyDashboardSignalAt = Date() } if codeReview != nil, usageBreakdown.isEmpty, @@ -220,12 +290,19 @@ public struct OpenAIDashboardFetcher { lastUsageBreakdownDebug = debug log("usage breakdown debug: \(debug)") } + Self.updateUsageBreakdownErrorState( + usageBreakdown: usageBreakdown, + error: scrape.usageBreakdownError, + firstSeenAt: &usageBreakdownErrorFirstSeenAt, + lastError: &lastUsageBreakdownError, + logger: log) if let purchaseURL = scrape.creditsPurchaseURL, purchaseURL != lastCreditsPurchaseURL { lastCreditsPurchaseURL = purchaseURL log("credits purchase url: \(purchaseURL)") } if events.isEmpty, - codeReview != nil || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil + hasReturnableData, + hasDashboardPageSignal { log( "credits header present=\(scrape.creditsHeaderPresent) " + @@ -255,9 +332,17 @@ public struct OpenAIDashboardFetcher { } } - if codeReview != nil || !events.isEmpty || !usageBreakdown - .isEmpty || hasUsageLimits || creditsRemaining != nil - { + if hasReturnableData, hasDashboardPageSignal { + if usageBreakdown.isEmpty, + let error = scrape.usageBreakdownError, !error.isEmpty, + Self.shouldWaitForUsageBreakdownRecovery(.init( + now: Date(), + errorFirstSeenAt: usageBreakdownErrorFirstSeenAt)) + { + try? await Task.sleep(for: .milliseconds(400)) + continue + } + // The usage breakdown chart is hydrated asynchronously. When code review is already present, // give it a moment to populate so the menu can show it. if codeReview != nil, usageBreakdown.isEmpty { @@ -268,15 +353,16 @@ public struct OpenAIDashboardFetcher { } } return Self.makeDashboardSnapshot(.init( + signedInEmail: dashboardData.signedInEmail, scrape: scrape, codeReview: codeReview, - codeReviewLimit: codeReviewLimit, + codeReviewLimit: dashboardData.codeReviewLimit, events: events, - breakdown: breakdown, + breakdown: dashboardData.breakdown, usageBreakdown: usageBreakdown, - rateLimits: rateLimits, - creditsRemaining: creditsRemaining, - accountPlan: accountPlan)) + rateLimits: dashboardData.rateLimits, + creditsRemaining: dashboardData.creditsRemaining, + accountPlan: dashboardData.accountPlan)) } try? await Task.sleep(for: .milliseconds(500)) @@ -285,64 +371,24 @@ public struct OpenAIDashboardFetcher { if debugDumpHTML, let html = lastHTML { Self.writeDebugArtifacts(html: html, bodyText: lastBody, logger: log) } - throw FetchError.noDashboardData(body: lastBody ?? "") - } - - struct CreditsHistoryWaitContext { - let now: Date - let anyDashboardSignalAt: Date? - let creditsHeaderVisibleAt: Date? - let creditsHeaderPresent: Bool - let creditsHeaderInViewport: Bool - let didScrollToCredits: Bool - } - - nonisolated static func shouldWaitForCreditsHistory(_ context: CreditsHistoryWaitContext) -> Bool { - if context.didScrollToCredits { return true } - - // When the header is visible but rows are still empty, wait briefly for the table to render. - if context.creditsHeaderPresent, context.creditsHeaderInViewport { - if let creditsHeaderVisibleAt = context.creditsHeaderVisibleAt { - return context.now.timeIntervalSince(creditsHeaderVisibleAt) < 2.5 - } - return true - } - - // Header not in view yet: allow a short grace period after we first detect any dashboard signal so - // a scroll (or hydration) can bring the credits section into the DOM. - if let anyDashboardSignalAt = context.anyDashboardSignalAt { - return context.now.timeIntervalSince(anyDashboardSignalAt) < 6.5 - } - return false + throw FetchError.noDashboardData(body: lastUsageBreakdownError ?? lastBody ?? "") } - struct ProbeReadinessContext { - let now: Date - let usageRouteSeenAt: Date? - let dashboardSignalSeenAt: Date? - let signedInEmail: String? - let hasDashboardSignal: Bool + nonisolated static func hasReturnableDashboardData( + codeReview: Double?, + events: [CreditEvent], + usageBreakdown: [OpenAIDashboardDailyBreakdown], + hasUsageLimits: Bool, + creditsRemaining: Double?) -> Bool + { + codeReview != nil || !events.isEmpty || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil } - nonisolated static func shouldWaitForProbeReadiness(_ context: ProbeReadinessContext) -> Bool { - if let signedInEmail = context.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), - !signedInEmail.isEmpty - { - return false - } - - if context.hasDashboardSignal { - if let dashboardSignalSeenAt = context.dashboardSignalSeenAt { - return context.now.timeIntervalSince(dashboardSignalSeenAt) < 2.0 - } - return true - } - - if let usageRouteSeenAt = context.usageRouteSeenAt { - return context.now.timeIntervalSince(usageRouteSeenAt) < 2.0 - } - - return false + nonisolated static func hasAnyDashboardSignal( + hasReturnableData: Bool, + creditsHeaderPresent: Bool) -> Bool + { + hasReturnableData || creditsHeaderPresent } public func clearSessionData(accountEmail: String?) async { @@ -389,7 +435,7 @@ public struct OpenAIDashboardFetcher { if let href = scrape.href, !Self.isUsageRoute(href) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil - _ = webView.load(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } @@ -466,6 +512,7 @@ public struct OpenAIDashboardFetcher { let rows: [[String]] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdownDebug: String? + let usageBreakdownError: String? let scrollY: Double let scrollHeight: Double let viewportHeight: Double @@ -489,6 +536,7 @@ public struct OpenAIDashboardFetcher { rows: [], usageBreakdown: [], usageBreakdownDebug: nil, + usageBreakdownError: nil, scrollY: 0, scrollHeight: 0, viewportHeight: 0, @@ -505,10 +553,12 @@ public struct OpenAIDashboardFetcher { var usageBreakdown: [OpenAIDashboardDailyBreakdown] = [] let usageBreakdownDebug = dict["usageBreakdownDebug"] as? String + let usageBreakdownError = dict["usageBreakdownError"] as? String if let raw = dict["usageBreakdownJSON"] as? String, !raw.isEmpty { do { let decoder = JSONDecoder() - usageBreakdown = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + let decoded = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: decoded) } catch { // Best-effort parse; ignore errors to avoid blocking other dashboard data. usageBreakdown = [] @@ -542,6 +592,7 @@ public struct OpenAIDashboardFetcher { rows: rows, usageBreakdown: usageBreakdown, usageBreakdownDebug: usageBreakdownDebug, + usageBreakdownError: usageBreakdownError, scrollY: (dict["scrollY"] as? NSNumber)?.doubleValue ?? 0, scrollHeight: (dict["scrollHeight"] as? NSNumber)?.doubleValue ?? 0, viewportHeight: (dict["viewportHeight"] as? NSNumber)?.doubleValue ?? 0, @@ -550,6 +601,26 @@ public struct OpenAIDashboardFetcher { didScrollToCredits: (dict["didScrollToCredits"] as? Bool) ?? false) } + private static func throwIfBlockingScrapeState( + _ scrape: ScrapeResult, + debugDumpHTML: Bool, + logger: (String) -> Void) throws + { + if scrape.loginRequired { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.loginRequired + } + + if scrape.cloudflareInterstitial { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") + } + } + private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, @@ -587,6 +658,180 @@ public struct OpenAIDashboardFetcher { || path.hasSuffix("codex/cloud/settings/analytics") } + nonisolated static func usageURLRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + return request + } + + nonisolated static func dashboardUsageAPIRequest(cookieHeader: String) -> URLRequest { + var request = URLRequest(url: Self.dashboardUsageAPIURL) + request.httpMethod = "GET" + request.timeoutInterval = 4 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardIdentityAPIRequest(url: URL, cookieHeader: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 2 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardAPIData(from response: CodexUsageResponse) -> DashboardAPIData { + DashboardAPIData( + primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow), + secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow), + creditsRemaining: response.credits?.balance, + accountPlan: response.planType?.rawValue) + } + + private static func fetchDashboardAPIPreflight( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) + async -> (apiData: DashboardAPIData?, verifiedSignedInEmail: String?) + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + let apiData = await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + let verifiedEmail: String? = if apiData?.hasUsageData == true { + await self.fetchSignedInEmailFromAPI(cookieHeader: cookieHeader, logger: logger) + } else { + nil + } + + if apiData?.hasUsageData == true, verifiedEmail != nil { + logger("usage api supplied verified dashboard data; continuing WebView scrape") + } + return (apiData, verifiedEmail) + } + + private static func fetchDashboardUsageAPI( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + return await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + } + + private static func fetchDashboardUsageAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + guard !cookieHeader.isEmpty else { return nil } + + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("usage api status=\(status)") + guard status >= 200, status < 300 else { return nil } + let decoded = try JSONDecoder().decode(CodexUsageResponse.self, from: data) + let result = self.dashboardAPIData(from: decoded) + if result.hasUsageData { + logger("usage api supplied language-independent rate/credit data") + } + return result + } catch { + logger("usage api unavailable: \(error.localizedDescription)") + return nil + } + } + + private static func fetchSignedInEmailFromAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> String? + { + guard !cookieHeader.isEmpty else { return nil } + + let endpoints = [ + URL(string: "https://chatgpt.com/backend-api/me"), + URL(string: "https://chatgpt.com/api/auth/session"), + ].compactMap(\.self) + + for url in endpoints { + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardIdentityAPIRequest(url: url, cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("identity api \(url.path) status=\(status)") + guard status >= 200, status < 300 else { continue } + if let email = self.findFirstEmail(inJSONData: data) { + return email.trimmingCharacters(in: .whitespacesAndNewlines) + } + } catch { + logger("identity api \(url.path) unavailable: \(error.localizedDescription)") + } + } + + return nil + } + + private static func chatGPTCookieHeader(in store: WKWebsiteDataStore) async -> String { + let cookies = await withCheckedContinuation { continuation in + store.httpCookieStore.getAllCookies { cookies in + continuation.resume(returning: cookies) + } + } + + return cookies + .filter { $0.domain.lowercased().contains("chatgpt.com") } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + } + + nonisolated static func findFirstEmail(inJSONData data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + var queue: [Any] = [json] + var seen = 0 + while !queue.isEmpty, seen < 2000 { + let current = queue.removeFirst() + seen += 1 + if let string = current as? String, string.contains("@") { + return string + } + if let dictionary = current as? [String: Any] { + for (key, value) in dictionary { + if key.lowercased() == "email", + let string = value as? String, + string.contains("@") + { + return string + } + queue.append(value) + } + } else if let array = current as? [Any] { + queue.append(contentsOf: array) + } + } + return nil + } + + private nonisolated static func rateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: UsageFormatter.resetDescription(from: resetDate)) + } + + private nonisolated static func firstNonEmpty(_ candidates: String?...) -> String? { + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed?.isEmpty == false { return trimmed } + } + return nil + } + private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { let stamp = Int(Date().timeIntervalSince1970) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) @@ -609,6 +854,93 @@ public struct OpenAIDashboardFetcher { } } } + +extension OpenAIDashboardFetcher { + struct CreditsHistoryWaitContext { + let now: Date + let anyDashboardSignalAt: Date? + let creditsHeaderVisibleAt: Date? + let creditsHeaderPresent: Bool + let creditsHeaderInViewport: Bool + let didScrollToCredits: Bool + } + + nonisolated static func shouldWaitForCreditsHistory(_ context: CreditsHistoryWaitContext) -> Bool { + if context.didScrollToCredits { return true } + + // When the header is visible but rows are still empty, wait briefly for the table to render. + if context.creditsHeaderPresent, context.creditsHeaderInViewport { + if let creditsHeaderVisibleAt = context.creditsHeaderVisibleAt { + return context.now.timeIntervalSince(creditsHeaderVisibleAt) < 2.5 + } + return true + } + + // Header not in view yet: allow a short grace period after we first detect any dashboard signal so + // a scroll (or hydration) can bring the credits section into the DOM. + if let anyDashboardSignalAt = context.anyDashboardSignalAt { + return context.now.timeIntervalSince(anyDashboardSignalAt) < 6.5 + } + return false + } + + struct ProbeReadinessContext { + let now: Date + let usageRouteSeenAt: Date? + let dashboardSignalSeenAt: Date? + let signedInEmail: String? + let hasDashboardSignal: Bool + } + + struct UsageBreakdownRecoveryContext { + let now: Date + let errorFirstSeenAt: Date? + } + + nonisolated static func shouldWaitForUsageBreakdownRecovery(_ context: UsageBreakdownRecoveryContext) -> Bool { + guard let errorFirstSeenAt = context.errorFirstSeenAt else { return true } + return context.now.timeIntervalSince(errorFirstSeenAt) < 4.0 + } + + nonisolated static func shouldWaitForProbeReadiness(_ context: ProbeReadinessContext) -> Bool { + if let signedInEmail = context.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines), + !signedInEmail.isEmpty + { + return false + } + + if context.hasDashboardSignal { + if let dashboardSignalSeenAt = context.dashboardSignalSeenAt { + return context.now.timeIntervalSince(dashboardSignalSeenAt) < 2.0 + } + return true + } + + if let usageRouteSeenAt = context.usageRouteSeenAt { + return context.now.timeIntervalSince(usageRouteSeenAt) < 2.0 + } + + return false + } + + nonisolated static func updateUsageBreakdownErrorState( + usageBreakdown: [OpenAIDashboardDailyBreakdown], + error: String?, + firstSeenAt: inout Date?, + lastError: inout String?, + now: Date = Date(), + logger: (String) -> Void) + { + guard usageBreakdown.isEmpty, let error, !error.isEmpty else { + firstSeenAt = nil + return + } + if firstSeenAt == nil { firstSeenAt = now } + guard error != lastError else { return } + lastError = error + logger("usage breakdown error: \(error)") + } +} #else import Foundation diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift index d9be82bbd..bb1872286 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift @@ -153,11 +153,49 @@ public enum OpenAIDashboardParser { } private static func parseCreditsUsed(_ text: String) -> Double { - let cleaned = text - .replacingOccurrences(of: ",", with: "") - .replacingOccurrences(of: "credits", with: "", options: .caseInsensitive) - .trimmingCharacters(in: .whitespacesAndNewlines) - return Double(cleaned) ?? 0 + guard let raw = self.firstNumberToken(in: text) else { return 0 } + let token = raw + .replacingOccurrences(of: "\u{00A0}", with: "") + .replacingOccurrences(of: "\u{202F}", with: "") + .replacingOccurrences(of: " ", with: "") + let hasComma = token.contains(",") + let hasDot = token.contains(".") + if hasComma, hasDot { + return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0 + } + if hasComma { + if self.usesLocalizedDecimalCommaCreditLabel(text) { + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil { + return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0 + } + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + return Double(token) ?? 0 + } + + private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool { + text + .lowercased() + .contains("crédit") + } + + private static func firstNumberToken(in text: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, + options: []) + else { + return nil + } + let range = NSRange(text.startIndex..= 2, + let tokenRange = Range(match.range(at: 1), in: text) + else { + return nil + } + return String(text[tokenRange]) } // MARK: - Private @@ -294,6 +332,7 @@ public enum OpenAIDashboardParser { private static func isFiveHourLimitLine(_ line: String) -> Bool { let lower = line.lowercased() if lower.contains("5h") { return true } + if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true } if lower.contains("5-hour") { return true } if lower.contains("5 hour") { return true } return false @@ -305,6 +344,7 @@ public enum OpenAIDashboardParser { if lower.contains("7-day") { return true } if lower.contains("7 day") { return true } if lower.contains("7d") { return true } + if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true } return false } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift index 06d7dea61..ce1f76037 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift @@ -227,9 +227,14 @@ let openAIDashboardScrapeScript = """ } return null; }; + const isSkillUsageServiceKey = (raw) => { + const key = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return key.startsWith('skillusage:'); + }; const displayNameForUsageServiceKey = (raw) => { const key = raw === null || raw === undefined ? '' : String(raw).trim(); if (!key) return key; + if (isSkillUsageServiceKey(key)) return null; if (key.toUpperCase() === key && key.length <= 6) return key; const lower = key.toLowerCase(); if (lower === 'cli') return 'CLI'; @@ -237,20 +242,136 @@ let openAIDashboardScrapeScript = """ const words = lower.replace(/[_-]+/g, ' ').split(' ').filter(Boolean); return words.map(w => w.length <= 2 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }; - const usageBreakdownJSON = (() => { + const isLikelyCodexUsageService = (raw) => { + const service = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return ( + service === 'cli' || + service === 'desktop' || + service === 'desktop app' || + service === 'vscode' || + service === 'vs code' || + service === 'unknown' || + (service.includes('github') && service.includes('review')) + ); + }; + const usageChartRootForPath = (path) => { + if (!path || !path.closest) return null; + return ( + path.closest('.recharts-wrapper') || + path.closest('svg.recharts-surface') || + path.closest('section') || + path.parentElement || + null + ); + }; + const uniqueUsageChartRoots = (paths) => { + const roots = []; + for (const path of paths) { + const root = usageChartRootForPath(path); + if (root && !roots.includes(root)) roots.push(root); + } + return roots; + }; + const usageBreakdownTitleScore = (title) => { + const lower = String(title || '').trim().toLowerCase().replace(/\\s+/g, ' '); + if (!lower) return 0; + if (lower === 'usage breakdown') return 1000000; + if (lower.includes('usage breakdown')) return 900000; + if (lower === 'personal usage') return 800000; + if (lower.includes('threads') || + lower.includes('turns') || + lower.includes('client') || + lower.includes('skill') || + lower.includes('invocation')) return -1000000; + return 0; + }; + const titleLikeElements = (scope) => { try { - if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; - - const sections = Array.from(document.querySelectorAll('section')); - const usageSection = sections.find(s => { - const h2 = s.querySelector('h2'); - return h2 && textOf(h2).toLowerCase().startsWith('usage breakdown'); - }); - if (!usageSection) return null; + return Array.from(scope.querySelectorAll('h1,h2,h3,[role=\"heading\"],div,span,p')) + .filter(el => { + const title = textOf(el); + const lower = title.toLowerCase(); + const tag = el.tagName ? el.tagName.toLowerCase() : ''; + const isHeading = tag === 'h1' || + tag === 'h2' || + tag === 'h3' || + String(el.getAttribute('role') || '').toLowerCase() === 'heading'; + return title.length > 0 && + title.length <= 80 && + ( + isHeading || + usageBreakdownTitleScore(title) !== 0 || + lower.includes('usage breakdown') || + lower.includes('threads') || + lower.includes('turns') || + lower.includes('client') || + lower.includes('skill') || + lower.includes('invocation') + ); + }); + } catch { + return []; + } + }; + const titleNodePrecedesRoot = (titleNode, root) => { + if (!titleNode || titleNode === root || root.contains(titleNode) || titleNode.contains(root)) return false; + const relation = titleNode.compareDocumentPosition(root); + return Boolean(relation & Node.DOCUMENT_POSITION_FOLLOWING); + }; + const nearestScoredChartTitleInScope = (scope, root) => { + let best = null; + for (const titleNode of titleLikeElements(scope)) { + if (!titleNodePrecedesRoot(titleNode, root)) continue; + const title = textOf(titleNode); + const score = usageBreakdownTitleScore(title); + if (score === 0) continue; + if (!best || score >= best.score) best = { title, score }; + } + return best ? best.title : ''; + }; + const chartTitleBoundaryForRoot = (root) => { + if (!root) return null; + try { + return root.closest('section,[role=\"region\"],article') || root.parentElement || null; + } catch { + return root.parentElement || null; + } + }; + const nearestTitleTextInScope = (scope, root) => { + if (!scope) return ''; + let nearest = null; + for (const titleNode of titleLikeElements(scope)) { + if (titleNodePrecedesRoot(titleNode, root)) nearest = titleNode; + } + return textOf(nearest); + }; + const nearestChartTitleTextForRoot = (root) => { + if (!root) return ''; + try { + const boundary = chartTitleBoundaryForRoot(root) || root.parentElement || null; + let ancestor = root.parentElement || null; + for (let i = 0; i < 8 && ancestor; i++) { + const scoredTitle = nearestScoredChartTitleInScope(ancestor, root); + if (scoredTitle) return scoredTitle; + if (ancestor === boundary) break; + ancestor = ancestor.parentElement || null; + } - const legendMap = {}; + return nearestTitleTextInScope(boundary, root); + } catch { + return ''; + } + }; + const legendMapForUsageChartRoot = (root) => { + const legendMap = {}; + const scopes = [ + root, + root && root.parentElement, + root && root.closest ? root.closest('section') : null + ].filter(Boolean); + for (const scope of scopes) { try { - const legendItems = Array.from(usageSection.querySelectorAll('div[title]')); + const legendItems = Array.from(scope.querySelectorAll('div[title]')); for (const item of legendItems) { const title = item.getAttribute('title') ? String(item.getAttribute('title')).trim() : ''; const square = item.querySelector('div[style*=\"background-color\"]'); @@ -261,11 +382,92 @@ let openAIDashboardScrapeScript = """ if (title && hex) legendMap[hex] = title; } } catch {} + if (Object.keys(legendMap).length > 0) break; + } + return legendMap; + }; + const parseUsageBreakdownFromChartPaths = (paths, legendMap) => { + const totalsByDay = {}; // day -> service -> value + const addValue = (day, service, value) => { + if (!day || !service || isSkillUsageServiceKey(service)) return false; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return false; + if (!totalsByDay[day]) totalsByDay[day] = {}; + totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; + return true; + }; + let pointCount = 0; + for (const path of paths) { + const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; + if (!meta) continue; - const totalsByDay = {}; // day -> service -> value - const paths = Array.from(usageSection.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); + const payload = meta.payload || null; + const day = dayKeyFromPayload(payload); + if (!day) continue; + + const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; + if (valuesObj) { + for (const [k, v] of Object.entries(valuesObj)) { + const service = displayNameForUsageServiceKey(k); + if (addValue(day, service, v)) pointCount++; + } + continue; + } + + let value = null; + if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; + if (value === null && typeof meta.value === 'string') { + const v = parseFloat(meta.value.replace(/,/g, '')); + if (Number.isFinite(v)) value = v; + } + if (value === null) continue; + + const fill = parseHexColor(meta.fill || path.getAttribute('fill')); + const service = + (fill && legendMap[fill]) || + (typeof meta.name === 'string' && meta.name) || + null; + if (addValue(day, service, value)) pointCount++; + } + + const dayKeys = Object.keys(totalsByDay) + .filter(day => Object.keys(totalsByDay[day] || {}).length > 0) + .sort((a, b) => b.localeCompare(a)) + .slice(0, 30); + const breakdown = dayKeys.map(day => { + const servicesMap = totalsByDay[day] || {}; + const services = Object.keys(servicesMap).map(service => ({ + service, + creditsUsed: servicesMap[service] + })).sort((a, b) => { + if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); + return b.creditsUsed - a.creditsUsed; + }); + const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); + return { day, services, totalCreditsUsed }; + }); + const services = Array.from(new Set(breakdown.flatMap(day => day.services.map(service => service.service)))); + const totalCreditsUsed = breakdown.reduce((sum, day) => sum + (Number(day.totalCreditsUsed) || 0), 0); + const likelyCodexServiceCount = services.filter(isLikelyCodexUsageService).length; + return { + breakdown, + pointCount, + services, + totalCreditsUsed, + likelyCodexServiceCount, + score: likelyCodexServiceCount * 1000 + services.length * 100 + pointCount + totalCreditsUsed / 1000 + }; + }; + const usageBreakdownJSON = (() => { + try { + if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; + + const paths = Array.from(document.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); let debug = { pathCount: paths.length, + chartCount: 0, + eligibleCandidateCount: 0, + selectedCandidateTitle: null, + candidateSummaries: [], sampleReactKeys: null, sampleMetaKeys: null, samplePayloadKeys: null, @@ -292,59 +494,51 @@ let openAIDashboardScrapeScript = """ } } } catch {} - for (const path of paths) { - const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; - if (!meta) continue; - - const payload = meta.payload || null; - const day = dayKeyFromPayload(payload); - if (!day) continue; - const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; - if (valuesObj) { - if (!totalsByDay[day]) totalsByDay[day] = {}; - for (const [k, v] of Object.entries(valuesObj)) { - if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue; - const service = displayNameForUsageServiceKey(k); - if (!service) continue; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + v; - } - continue; - } - - let value = null; - if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; - if (value === null && typeof meta.value === 'string') { - const v = parseFloat(meta.value.replace(/,/g, '')); - if (Number.isFinite(v)) value = v; + const roots = uniqueUsageChartRoots(paths); + debug.chartCount = roots.length; + const candidates = roots.map(root => { + const chartPaths = paths.filter(path => usageChartRootForPath(path) === root); + const title = nearestChartTitleTextForRoot(root); + const titleScore = usageBreakdownTitleScore(title); + const parsed = parseUsageBreakdownFromChartPaths(chartPaths, legendMapForUsageChartRoot(root)); + return { + root, + title, + titleScore, + pathCount: chartPaths.length, + ...parsed, + score: titleScore + parsed.score + }; + }).filter(candidate => candidate.breakdown.length > 0); + const rejectedTitleCandidates = candidates.filter(candidate => candidate.titleScore < 0); + const titledCandidates = candidates.filter(candidate => candidate.titleScore > 0); + const unknownTitleCandidates = candidates.filter(candidate => candidate.titleScore === 0); + const eligibleCandidates = titledCandidates; + eligibleCandidates.sort((a, b) => b.score - a.score); + debug.eligibleCandidateCount = eligibleCandidates.length; + debug.selectedCandidateTitle = eligibleCandidates[0] ? eligibleCandidates[0].title : null; + if (eligibleCandidates.length === 0 && candidates.length > 0) { + if (unknownTitleCandidates.length > 0) { + debug.error = 'No English usage breakdown chart title found. Candidate titles: ' + + candidates.map(candidate => candidate.title || 'Untitled chart').join(', '); + } else if (rejectedTitleCandidates.length > 0) { + debug.error = 'Only non-usage chart candidates found: ' + + rejectedTitleCandidates.map(candidate => candidate.title || 'Untitled chart').join(', '); } - if (value === null) continue; - - const fill = parseHexColor(meta.fill || path.getAttribute('fill')); - const service = - (fill && legendMap[fill]) || - (typeof meta.name === 'string' && meta.name) || - null; - if (!service) continue; - - if (!totalsByDay[day]) totalsByDay[day] = {}; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; } + debug.candidateSummaries = candidates.slice(0, 6).map(candidate => ({ + title: candidate.title, + titleScore: candidate.titleScore, + pathCount: candidate.pathCount, + dayCount: candidate.breakdown.length, + pointCount: candidate.pointCount, + serviceCount: candidate.services.length, + likelyCodexServiceCount: candidate.likelyCodexServiceCount, + services: candidate.services.slice(0, 8) + })); - const dayKeys = Object.keys(totalsByDay).sort((a, b) => b.localeCompare(a)).slice(0, 30); - const breakdown = dayKeys.map(day => { - const servicesMap = totalsByDay[day] || {}; - const services = Object.keys(servicesMap).map(service => ({ - service, - creditsUsed: servicesMap[service] - })).sort((a, b) => { - if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); - return b.creditsUsed - a.creditsUsed; - }); - const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); - return { day, services, totalCreditsUsed }; - }); - + const breakdown = eligibleCandidates[0] ? eligibleCandidates[0].breakdown : []; const json = (breakdown.length > 0) ? JSON.stringify(breakdown) : null; window.__codexbarUsageBreakdownJSON = json; window.__codexbarUsageBreakdownDebug = json ? null : JSON.stringify(debug); @@ -360,6 +554,15 @@ let openAIDashboardScrapeScript = """ return null; } })(); + const usageBreakdownError = (() => { + try { + if (!usageBreakdownDebug) return null; + const parsed = JSON.parse(usageBreakdownDebug); + return parsed && parsed.error ? String(parsed.error) : null; + } catch { + return null; + } + })(); const bodyText = document.body ? String(document.body.innerText || '').trim() : ''; const href = window.location ? String(window.location.href || '') : ''; const workspacePicker = bodyText.includes('Select a workspace'); @@ -395,6 +598,16 @@ let openAIDashboardScrapeScript = """ let didScrollToCredits = false; let rows = []; try { + const looksLikeCreditsEventRow = (cells) => { + if (!cells || cells.length < 3) return false; + const first = String(cells[0] || ''); + const amount = String(cells[2] || ''); + return /\\d{4}|\\d{1,2}[\\/.\\-]\\d{1,2}/.test(first) && /\\d/.test(amount); + }; + const allTableRows = () => Array.from(document.querySelectorAll('tbody tr')).map(tr => { + const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); + return cells; + }).filter(looksLikeCreditsEventRow); const headings = Array.from(document.querySelectorAll('h1,h2,h3')); const header = headings.find(h => textOf(h).toLowerCase() === 'credits usage history'); if (header) { @@ -411,6 +624,9 @@ let openAIDashboardScrapeScript = """ const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); return cells; }).filter(r => r.length >= 3); + if (rows.length === 0) { + rows = allTableRows(); + } if (rows.length === 0 && !window.__codexbarDidScrollToCredits) { window.__codexbarDidScrollToCredits = true; // If the table is virtualized/lazy-loaded, we need to scroll to trigger rendering even if the @@ -422,6 +638,13 @@ let openAIDashboardScrapeScript = """ didScrollToCredits = true; } } else if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { + rows = allTableRows(); + if (rows.length > 0) { + creditsHeaderPresent = true; + creditsHeaderInViewport = true; + } + } + if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { // The credits history section often isn't part of the DOM until you scroll down. Nudge the page // once so subsequent scrapes can find the header and rows. window.__codexbarDidScrollToCredits = true; @@ -551,6 +774,7 @@ let openAIDashboardScrapeScript = """ rows, usageBreakdownJSON, usageBreakdownDebug, + usageBreakdownError, scrollY, scrollHeight, viewportHeight, diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1a90d2816..3706bc312 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -98,6 +98,22 @@ final class OpenAIDashboardWebViewCache { } })(); """ + private let preferredLanguageScript = """ + (() => { + const define = (target, name, value) => { + try { + Object.defineProperty(target, name, { + get: () => value, + configurable: true + }); + } catch {} + }; + define(Navigator.prototype, 'language', 'en-US'); + define(Navigator.prototype, 'languages', ['en-US', 'en']); + define(navigator, 'language', 'en-US'); + define(navigator, 'languages', ['en-US', 'en']); + })(); + """ private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false @@ -431,6 +447,12 @@ final class OpenAIDashboardWebViewCache { private func makeWebView(websiteDataStore: WKWebsiteDataStore) -> (WKWebView, OffscreenWebViewHost) { let config = WKWebViewConfiguration() config.websiteDataStore = websiteDataStore + let userContentController = WKUserContentController() + userContentController.addUserScript(WKUserScript( + source: self.preferredLanguageScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false)) + config.userContentController = userContentController if #available(macOS 14.0, *) { config.preferences.inactiveSchedulingPolicy = .suspend } @@ -471,7 +493,7 @@ final class OpenAIDashboardWebViewCache { webView.navigationDelegate = delegate webView.codexNavigationDelegate = delegate delegate.armTimeout(seconds: timeout) - _ = webView.load(URLRequest(url: usageURL)) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 1e0881efc..6d0a97fd0 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif public enum PathPurpose: Hashable, Sendable { case rpc @@ -112,6 +117,39 @@ public enum BinaryLocator { home: home) } + public static func resolveGrokBinary( + 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: "grok", + overrideKey: "GROK_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: self.grokWellKnownPaths(home: home), + fileManager: fileManager, + home: home) + } + + /// Well-known install locations for the Grok Build CLI binary. + /// Covers the installer's default (`~/.grok/bin/grok`) and the symlinks it sometimes + /// creates into `~/.local/bin` and `/usr/local/bin`. + static func grokWellKnownPaths(home: String) -> [String] { + [ + "\(home)/.grok/bin/grok", + "\(home)/.local/bin/grok", + "/usr/local/bin/grok", + "/opt/homebrew/bin/grok", + ] + } + public static func resolveAuggieBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, @@ -167,27 +205,26 @@ public enum BinaryLocator { return pathHit } - // 4) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) + // 4) Well-known installation paths (e.g. Homebrew, cmux.app bundle, ~/.claude/bin). + // Prefer these before shell probing to avoid running interactive shell init for common installs. + for candidate in wellKnownPaths where fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + + // 5) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), fileManager.isExecutableFile(atPath: shellHit) { return shellHit } - // 4b) Alias fallback (login shell); only attempt after all standard lookups fail. + // 5b) Alias fallback (login shell); only attempt after all standard lookups fail. if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), fileManager.isExecutableFile(atPath: aliasHit) { return aliasHit } - // 5) Well-known installation paths (e.g. cmux.app bundle, ~/.claude/bin) - // macOS apps launched from Finder may not inherit the user's shell PATH, - // so check common install locations that the shell-based lookups above may miss. - for candidate in wellKnownPaths where fileManager.isExecutableFile(atPath: candidate) { - return candidate - } - // 6) Minimal fallback let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] if let pathHit = self.find(name, in: fallback, fileManager: fileManager) { @@ -209,6 +246,14 @@ public enum BinaryLocator { } public enum ShellCommandLocator { + static func test_runShellCommand( + shell: String, + arguments: [String], + timeout: TimeInterval) -> Data? + { + self.runShellCommand(shell: shell, arguments: arguments, timeout: timeout) + } + public static func commandV( _ tool: String, _ shell: String?, @@ -256,34 +301,242 @@ public enum ShellCommandLocator { return nil } - private static func runShellCapture(_ shell: String?, _ timeout: TimeInterval, _ command: String) -> String? { - let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" - let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) - let process = Process() - process.executableURL = URL(fileURLWithPath: shellPath) - // Interactive login shell to pick up PATH mutations from shell init (nvm/fnm/mise). - // CI runners can have shell init hooks that emit missing CLI errors; avoid them in CI. - process.arguments = isCI ? ["-c", command] : ["-l", "-i", "-c", command] - let stdout = Pipe() - process.standardOutput = stdout - process.standardError = Pipe() - do { - try process.run() - } catch { + /// Thread-safe buffer for collecting pipe output from a readability handler. + private final class CapturedData: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ other: Data) { + self.lock.lock() + self.data.append(other) + self.lock.unlock() + } + + func drain() -> Data { + self.lock.lock() + let result = self.data + self.lock.unlock() + return result + } + } + + /// Idempotent one-shot flag — `fire()` returns true exactly once. + /// Used to make `DispatchGroup.leave()` safe to attempt from multiple paths. + private final class OnceFlag: @unchecked Sendable { + private let lock = NSLock() + private var fired = false + + func fire() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + if self.fired { return false } + self.fired = true + return true + } + } + + // swiftlint:disable cyclomatic_complexity + /// Runs a shell command, draining both stdout and stderr concurrently so that + /// verbose shell init scripts (oh-my-zsh, nvm, pyenv, etc.) cannot deadlock on + /// a full pipe buffer. The child is launched via `posix_spawn` with + /// `POSIX_SPAWN_SETPGROUP` so it becomes its own process-group leader *before* + /// `exec`, which guarantees that subsequent `kill(-pgid, ...)` calls reach any + /// background helpers spawned by shell init, on both the timeout-kill path and + /// after normal completion. + fileprivate static func runShellCommand( + shell: String, + arguments: [String], + timeout: TimeInterval) -> Data? + { + // Pipes for stdout/stderr. stdin is redirected from /dev/null in the child + // via posix_spawn_file_actions_addopen below. + var stdoutFds: (read: Int32, write: Int32) = (-1, -1) + var stderrFds: (read: Int32, write: Int32) = (-1, -1) + guard withUnsafeMutablePointer(to: &stdoutFds, { + $0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 } + }) else { return nil } + guard withUnsafeMutablePointer(to: &stderrFds, { + $0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 } + }) else { + close(stdoutFds.read); close(stdoutFds.write) + return nil + } + + // Build file actions: redirect stdin from /dev/null, dup pipe write ends to + // fds 1 and 2, and close every pipe fd in the child. The init pattern + // differs between platforms because the typedef is an opaque pointer on + // Darwin and a struct on Glibc. + #if canImport(Darwin) + var fileActions: posix_spawn_file_actions_t? + #else + var fileActions = posix_spawn_file_actions_t() + #endif + guard posix_spawn_file_actions_init(&fileActions) == 0 else { + close(stdoutFds.read); close(stdoutFds.write) + close(stderrFds.read); close(stderrFds.write) return nil } + defer { posix_spawn_file_actions_destroy(&fileActions) } + posix_spawn_file_actions_addopen(&fileActions, 0, "/dev/null", O_RDONLY, 0) + posix_spawn_file_actions_adddup2(&fileActions, stdoutFds.write, 1) + posix_spawn_file_actions_adddup2(&fileActions, stderrFds.write, 2) + posix_spawn_file_actions_addclose(&fileActions, stdoutFds.read) + posix_spawn_file_actions_addclose(&fileActions, stdoutFds.write) + posix_spawn_file_actions_addclose(&fileActions, stderrFds.read) + posix_spawn_file_actions_addclose(&fileActions, stderrFds.write) + + // Build attributes: set the child's process group to itself in the child, + // before exec, eliminating the race that an after-launch setpgid(2) has. + #if canImport(Darwin) + var attr: posix_spawnattr_t? + #else + var attr = posix_spawnattr_t() + #endif + guard posix_spawnattr_init(&attr) == 0 else { + close(stdoutFds.read); close(stdoutFds.write) + close(stderrFds.read); close(stderrFds.write) + return nil + } + defer { posix_spawnattr_destroy(&attr) } + posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP)) + posix_spawnattr_setpgroup(&attr, 0) // 0 = child becomes its own pgid leader + + // Build argv (argv[0] is conventionally the executable path). + var cArgs: [UnsafeMutablePointer?] = [] + cArgs.append(strdup(shell)) + for arg in arguments { + cArgs.append(strdup(arg)) + } + cArgs.append(nil) + defer { + for p in cArgs { + if let p { + free(p) + } + } + } + + // Inherit the parent environment. Build a NULL-terminated `KEY=VALUE` + // array since `extern char **environ` isn't directly visible from Swift. + var cEnv: [UnsafeMutablePointer?] = [] + for (key, value) in ProcessInfo.processInfo.environment { + cEnv.append(strdup("\(key)=\(value)")) + } + cEnv.append(nil) + defer { + for p in cEnv { + if let p { + free(p) + } + } + } - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) + var pid: pid_t = 0 + let spawnResult = shell.withCString { execPath in + posix_spawn(&pid, execPath, &fileActions, &attr, cArgs, cEnv) } - if process.isRunning { - process.terminate() + // Close the write ends in the parent so EOF will arrive on the read ends + // once every descendant in the process group also closes them. + close(stdoutFds.write) + close(stderrFds.write) + + guard spawnResult == 0 else { + close(stdoutFds.read); close(stderrFds.read) return nil } - let data = stdout.fileHandleForReading.readDataToEndOfFile() + // POSIX_SPAWN_SETPGROUP with pgroup=0 guarantees the child's pgid == its pid. + let pgid: pid_t = pid + + // Track EOF on each pipe so we can wait for full drain instead of sleeping. + // The readability handler fires with empty data when every writer end is + // closed (i.e. the child *and* any inheriting background helpers are gone). + let drainGroup = DispatchGroup() + drainGroup.enter() + drainGroup.enter() + let stdoutDone = OnceFlag() + let stderrDone = OnceFlag() + + let stdoutCollector = CapturedData() + let stdoutHandle = FileHandle(fileDescriptor: stdoutFds.read, closeOnDealloc: true) + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + if stdoutDone.fire() { drainGroup.leave() } + } else { + stdoutCollector.append(data) + } + } + + let stderrHandle = FileHandle(fileDescriptor: stderrFds.read, closeOnDealloc: true) + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + if stderrDone.fire() { drainGroup.leave() } + } + } + + // Reap the child on a background queue and signal a semaphore on exit. + let exitSemaphore = DispatchSemaphore(value: 0) + let waitPid = pid + DispatchQueue.global(qos: .userInitiated).async { + var status: Int32 = 0 + while waitpid(waitPid, &status, 0) == -1, errno == EINTR { + // retry + } + exitSemaphore.signal() + } + + let finishedInTime = exitSemaphore.wait(timeout: .now() + timeout) == .success + + if !finishedInTime { + kill(-pgid, SIGTERM) + kill(pid, SIGTERM) + if exitSemaphore.wait(timeout: .now() + 0.4) != .success { + kill(-pgid, SIGKILL) + kill(pid, SIGKILL) + _ = exitSemaphore.wait(timeout: .now() + 1.0) + } + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + if stdoutDone.fire() { drainGroup.leave() } + if stderrDone.fire() { drainGroup.leave() } + return nil + } + + // Normal completion — clean up any background children spawned by shell init. + // Without this, helpers that inherited stdout/stderr keep the pipe write ends + // open and we never see EOF on the read ends. + kill(-pgid, SIGTERM) + + // Wait for both pipes to deliver EOF so no buffered bytes are lost. + // Bounded so a stuck handler can't hang the caller indefinitely. + if drainGroup.wait(timeout: .now() + 0.4) != .success { + kill(-pgid, SIGKILL) + } + if drainGroup.wait(timeout: .now() + 0.6) != .success { + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + if stdoutDone.fire() { drainGroup.leave() } + if stderrDone.fire() { drainGroup.leave() } + } + return stdoutCollector.drain() + } + + // swiftlint:enable cyclomatic_complexity + + private static func runShellCapture(_ shell: String?, _ timeout: TimeInterval, _ command: String) -> String? { + let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" + let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) + // Interactive login shell to pick up PATH mutations from shell init (nvm/fnm/mise). + // CI runners can have shell init hooks that emit missing CLI errors; avoid them in CI. + let args = isCI ? ["-c", command] : ["-l", "-i", "-c", command] + guard let data = runShellCommand(shell: shellPath, arguments: args, timeout: timeout) else { + return nil + } return String(data: data, encoding: .utf8) } @@ -419,34 +672,17 @@ enum LoginShellPathCapturer { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) let marker = "__CODEXBAR_PATH__" - let process = Process() - process.executableURL = URL(fileURLWithPath: shellPath) // Skip interactive login shells in CI to avoid noisy init hooks. - process.arguments = isCI + let args = isCI ? ["-c", "printf '\(marker)%s\(marker)' \"$PATH\""] : ["-l", "-i", "-c", "printf '\(marker)%s\(marker)' \"$PATH\""] - let stdout = Pipe() - process.standardOutput = stdout - process.standardError = Pipe() - do { - try process.run() - } catch { - return nil - } - - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning, Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - - if process.isRunning { - process.terminate() - return nil - } - - let data = stdout.fileHandleForReading.readDataToEndOfFile() - guard let raw = String(data: data, encoding: .utf8), - !raw.isEmpty else { return nil } + guard let data = ShellCommandLocator.runShellCommand( + shell: shellPath, + arguments: args, + timeout: timeout), + let raw = String(data: data, encoding: .utf8), + !raw.isEmpty + else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let extracted = if let start = trimmed.range(of: marker), diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index b96e65021..a5946c2ef 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -1,7 +1,7 @@ import Foundation enum PiSessionCostCacheIO { - private static let artifactVersion = 1 + private static let artifactVersion = 2 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -50,7 +50,7 @@ struct PiSessionCostCache: Codable { var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 1) { + init(version: Int = 2) { self.version = version } } @@ -76,6 +76,7 @@ struct PiPackedUsage: Codable, Equatable { var totalTokens: Int = 0 var costNanos: Int64 = 0 var costSampleCount: Int = 0 + var usageSampleCount: Int? var isZero: Bool { self.inputTokens == 0 @@ -85,5 +86,6 @@ struct PiPackedUsage: Codable, Equatable { && self.totalTokens == 0 && self.costNanos == 0 && self.costSampleCount == 0 + && (self.usageSampleCount ?? 0) == 0 } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 49c9e6998..2fcce0c81 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -31,6 +31,11 @@ enum PiSessionCostScanner { let modelName: String } + private struct ModelsDevPricingContext { + let catalog: ModelsDevCatalog? + let cacheRoot: URL? + } + private static let costScale = 1_000_000_000.0 private static let maxLineBytes = 16 * 1024 * 1024 private static let maxSafeRoundedInt = Double(Int.max) - 1 @@ -50,6 +55,9 @@ enum PiSessionCostScanner { var cache = PiSessionCostCacheIO.load(cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) + let pricingContext = ModelsDevPricingContext( + catalog: CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot), + cacheRoot: options.cacheRoot) let windowExpanded = self.requestedWindowExpandsCache(range: range, cache: cache) let shouldRefresh = options.forceRescan || windowExpanded @@ -68,6 +76,7 @@ enum PiSessionCostScanner { fileURL: fileURL, range: range, forceRescan: options.forceRescan || windowExpanded, + pricingContext: pricingContext, cache: &cache) } @@ -87,7 +96,11 @@ enum PiSessionCostScanner { PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) } - return self.buildReport(provider: provider, cache: cache, range: range) + return self.buildReport( + provider: provider, + cache: cache, + range: range, + pricingContext: pricingContext) } private static func requestedWindowExpandsCache( @@ -165,6 +178,7 @@ enum PiSessionCostScanner { fileURL: URL, range: CostUsageScanner.CostUsageDayRange, forceRescan: Bool, + pricingContext: ModelsDevPricingContext, cache: inout PiSessionCostCache) { let path = fileURL.path @@ -196,7 +210,8 @@ enum PiSessionCostScanner { fileURL: fileURL, range: range, startOffset: cached.parsedBytes, - initialModelContext: cached.lastModelContext) + initialModelContext: cached.lastModelContext, + pricingContext: pricingContext) if !delta.contributions.isEmpty { self.applyContributions( daysByProvider: &cache.daysByProvider, @@ -220,7 +235,10 @@ enum PiSessionCostScanner { sign: -1) } - let parsed = self.parsePiSessionFile(fileURL: fileURL, range: range) + let parsed = self.parsePiSessionFile( + fileURL: fileURL, + range: range, + pricingContext: pricingContext) if !parsed.contributions.isEmpty { self.applyContributions(daysByProvider: &cache.daysByProvider, contributions: parsed.contributions, sign: 1) } @@ -237,7 +255,8 @@ enum PiSessionCostScanner { fileURL: URL, range: CostUsageScanner.CostUsageDayRange, startOffset: Int64 = 0, - initialModelContext: PiModelContext? = nil) -> ParseResult + initialModelContext: PiModelContext? = nil, + pricingContext: ModelsDevPricingContext? = nil) -> ParseResult { var currentModelContext = initialModelContext var contributions: [String: [String: [String: PiPackedUsage]]] = [:] @@ -280,30 +299,33 @@ enum PiSessionCostScanner { prefixBytes: Self.maxLineBytes, onLine: { line in guard !line.bytes.isEmpty, !line.wasTruncated else { return } - guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] - else { return } - guard let type = object["type"] as? String 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 + } - 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) - add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage) })) ?? startOffset return ParseResult( @@ -435,7 +457,8 @@ enum PiSessionCostScanner { private static func extractUsage( provider: UsageProvider, modelName: String, - message: [String: Any]) -> PiPackedUsage + message: [String: Any], + pricingContext: ModelsDevPricingContext? = nil) -> PiPackedUsage { let usage = (message["usage"] as? [String: Any]) ?? [:] let input = self.readNonNegativeInt( @@ -482,7 +505,11 @@ enum PiSessionCostScanner { cacheWriteTokens: cacheWrite, outputTokens: output, totalTokens: totalTokens) - let costUSD = self.computedCostUSD(provider: provider, modelName: modelName, usage: rawUsage) + let costUSD = self.computedCostUSD( + provider: provider, + modelName: modelName, + usage: rawUsage, + pricingContext: pricingContext) let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? 0 return PiPackedUsage( @@ -492,13 +519,15 @@ enum PiSessionCostScanner { outputTokens: rawUsage.outputTokens, totalTokens: rawUsage.totalTokens, costNanos: costNanos, - costSampleCount: costUSD == nil ? 0 : 1) + costSampleCount: costUSD == nil ? 0 : 1, + usageSampleCount: 1) } private static func computedCostUSD( provider: UsageProvider, modelName: String, - usage: PiPackedUsage) -> Double? + usage: PiPackedUsage, + pricingContext: ModelsDevPricingContext? = nil) -> Double? { switch provider { case .codex: @@ -506,14 +535,18 @@ enum PiSessionCostScanner { model: modelName, inputTokens: usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens, cachedInputTokens: usage.cacheReadTokens, - outputTokens: usage.outputTokens) + outputTokens: usage.outputTokens, + modelsDevCatalog: pricingContext?.catalog, + modelsDevCacheRoot: pricingContext?.cacheRoot) case .claude: CostUsagePricing.claudeCostUSD( model: modelName, inputTokens: usage.inputTokens, cacheReadInputTokens: usage.cacheReadTokens, cacheCreationInputTokens: usage.cacheWriteTokens, - outputTokens: usage.outputTokens) + outputTokens: usage.outputTokens, + modelsDevCatalog: pricingContext?.catalog, + modelsDevCacheRoot: pricingContext?.cacheRoot) default: nil } @@ -550,7 +583,8 @@ enum PiSessionCostScanner { private static func buildReport( provider: UsageProvider, cache: PiSessionCostCache, - range: CostUsageScanner.CostUsageDayRange) -> CostUsageDailyReport + range: CostUsageScanner.CostUsageDayRange, + pricingContext: ModelsDevPricingContext? = nil) -> CostUsageDailyReport { guard let providerDays = cache.daysByProvider[provider.rawValue] else { return CostUsageDailyReport(data: [], summary: nil) @@ -587,17 +621,31 @@ enum PiSessionCostScanner { let modelTotalTokens = max( packed.totalTokens, packed.inputTokens + packed.cacheReadTokens + packed.cacheWriteTokens + packed.outputTokens) + let currentPricingCost = self.computedCostUSD( + provider: provider, + modelName: modelName, + usage: packed, + pricingContext: pricingContext) + let usageSampleCount = packed.usageSampleCount + let hasCompleteCachedCost = (usageSampleCount ?? 0) > 0 + && packed.costSampleCount == usageSampleCount + // Cached costs are accumulated per message, which preserves Claude long-context threshold boundaries. + let costNanos = hasCompleteCachedCost + ? packed.costNanos + : currentPricingCost.map { Int64(($0 * self.costScale).rounded()) } breakdown.append(CostUsageDailyReport.ModelBreakdown( modelName: modelName, - costUSD: packed.costSampleCount > 0 ? Double(packed.costNanos) / Self.costScale : nil, + costUSD: costNanos.map { Double($0) / Self.costScale }, totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil)) dayInput += packed.inputTokens dayOutput += packed.outputTokens dayCacheRead += packed.cacheReadTokens dayCacheWrite += packed.cacheWriteTokens dayTotalTokens += modelTotalTokens - dayCostNanos += packed.costNanos - dayCostSamples += packed.costSampleCount + if let costNanos { + dayCostNanos += costNanos + dayCostSamples += 1 + } } let sortedBreakdown = self.sortedModelBreakdowns(breakdown) @@ -677,14 +725,23 @@ enum PiSessionCostScanner { } private static func addPacked(a: PiPackedUsage, b: PiPackedUsage, sign: Int) -> PiPackedUsage { - PiPackedUsage( + let aUsageSampleCount = a.usageSampleCount ?? (a.isZero ? 0 : nil) + let bUsageSampleCount = b.usageSampleCount ?? (b.isZero ? 0 : nil) + let usageSampleCount: Int? = if let aCount = aUsageSampleCount, let bCount = bUsageSampleCount { + max(0, aCount + sign * bCount) + } else { + nil + } + + return PiPackedUsage( inputTokens: max(0, a.inputTokens + sign * b.inputTokens), cacheReadTokens: max(0, a.cacheReadTokens + sign * b.cacheReadTokens), cacheWriteTokens: max(0, a.cacheWriteTokens + sign * b.cacheWriteTokens), outputTokens: max(0, a.outputTokens + sign * b.outputTokens), totalTokens: max(0, a.totalTokens + sign * b.totalTokens), costNanos: max(0, a.costNanos + Int64(sign) * b.costNanos), - costSampleCount: max(0, a.costSampleCount + sign * b.costSampleCount)) + costSampleCount: max(0, a.costSampleCount + sign * b.costSampleCount), + usageSampleCount: usageSampleCount) } private static func parseSessionStartFromFilename(_ filename: String) -> Date? { diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift new file mode 100644 index 000000000..1c8a3f85a --- /dev/null +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -0,0 +1,101 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public protocol ProviderHTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +#if !os(Linux) +extension URLSession: ProviderHTTPTransport {} +#endif + +extension URLSession { + 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) + } + return ProviderHTTPResponse(data: data, response: httpResponse) + } +} + +public struct ProviderHTTPResponse: Sendable { + public let data: Data + public let response: HTTPURLResponse + + public init(data: Data, response: HTTPURLResponse) { + self.data = data + self.response = response + } + + public var statusCode: Int { + self.response.statusCode + } +} + +public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { + private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + public init(_ handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { + self.handler = handler + } + + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await self.handler(request) + } +} + +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) + } + return ProviderHTTPResponse(data: data, response: httpResponse) + } +} + +public final class ProviderHTTPClient: ProviderHTTPTransport, @unchecked Sendable { + public static let shared = ProviderHTTPClient(session: ProviderHTTPClient.sharedSession()) + + private let session: URLSession + + public init(session: URLSession? = nil) { + self.session = session ?? URLSession(configuration: Self.defaultConfiguration()) + } + + static func defaultConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 90 + #if !os(Linux) + configuration.waitsForConnectivity = false + #endif + return configuration + } + + private static func sharedSession() -> URLSession { + if self.isRunningTests { + // XCTest URLProtocol.registerClass stubs only intercept URLSession.shared on macOS. + return .shared + } + return URLSession(configuration: self.defaultConfiguration()) + } + + private static var isRunningTests: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil { + return true + } + if ProcessInfo.processInfo.processName.lowercased().contains("xctest") { + return true + } + return CommandLine.arguments.contains { $0.lowercased().contains(".xctest") } + } + + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await self.session.data(for: request) + } +} diff --git a/Sources/CodexBarCore/ProviderStorageFootprint.swift b/Sources/CodexBarCore/ProviderStorageFootprint.swift new file mode 100644 index 000000000..ea5e62dd7 --- /dev/null +++ b/Sources/CodexBarCore/ProviderStorageFootprint.swift @@ -0,0 +1,520 @@ +import Foundation + +public struct ProviderStorageFootprint: Sendable, Equatable { + public struct Component: Sendable, Equatable, Identifiable { + public let id: String + public let path: String + public let totalBytes: Int64 + + public init(path: String, totalBytes: Int64) { + self.id = path + self.path = path + self.totalBytes = totalBytes + } + + public var name: String { + let url = URL(fileURLWithPath: self.path) + let last = url.lastPathComponent + if last.isEmpty { return self.path } + return last + } + } + + public let provider: UsageProvider + public let totalBytes: Int64 + public let paths: [String] + public let missingPaths: [String] + public let unreadablePaths: [String] + public let components: [Component] + public let updatedAt: Date + + public init( + provider: UsageProvider, + totalBytes: Int64, + paths: [String], + missingPaths: [String], + unreadablePaths: [String], + components: [Component] = [], + updatedAt: Date) + { + self.provider = provider + self.totalBytes = totalBytes + self.paths = paths + self.missingPaths = missingPaths + self.unreadablePaths = unreadablePaths + self.components = components + self.updatedAt = updatedAt + } + + public var hasLocalData: Bool { + self.totalBytes > 0 + } + + public var cleanupRecommendations: [ProviderStorageRecommendation] { + ProviderStorageRecommendation.recommendations(for: self) + } + + public func replacingProvider(_ provider: UsageProvider) -> ProviderStorageFootprint { + ProviderStorageFootprint( + provider: provider, + totalBytes: self.totalBytes, + paths: self.paths, + missingPaths: self.missingPaths, + unreadablePaths: self.unreadablePaths, + components: self.components, + updatedAt: self.updatedAt) + } +} + +public struct ProviderStorageRecommendation: Sendable, Equatable, Identifiable { + public enum RiskLevel: String, Sendable { + case informational + case manualCleanup + } + + public let id: String + public let provider: UsageProvider + public let path: String + public let bytes: Int64 + public let title: String + public let riskLevel: RiskLevel + public let consequence: String + public let sortPriority: Int + + public init( + provider: UsageProvider, + path: String, + bytes: Int64, + title: String, + riskLevel: RiskLevel, + consequence: String, + sortPriority: Int) + { + self.id = path + self.provider = provider + self.path = path + self.bytes = bytes + self.title = title + self.riskLevel = riskLevel + self.consequence = consequence + self.sortPriority = sortPriority + } + + public static func recommendations(for footprint: ProviderStorageFootprint) -> [ProviderStorageRecommendation] { + let candidates: [ProviderStorageRecommendation] = footprint.components.compactMap { component in + switch footprint.provider { + case .claude: + self.claudeRecommendation(for: component) + case .codex: + self.codexRecommendation(for: component, roots: footprint.paths) + default: + nil + } + } + + return candidates.sorted { lhs, rhs in + if lhs.sortPriority == rhs.sortPriority { + if lhs.bytes == rhs.bytes { + return lhs.path.localizedCaseInsensitiveCompare(rhs.path) == .orderedAscending + } + return lhs.bytes > rhs.bytes + } + return lhs.sortPriority < rhs.sortPriority + } + } + + private static func claudeRecommendation( + for component: ProviderStorageFootprint.Component) + -> ProviderStorageRecommendation? + { + switch component.name { + case "projects": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: past sessions", + consequence: "Clearing removes past resume, continue, and rewind history.", + priority: 10) + case "file-history": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: file checkpoints", + consequence: "Clearing removes checkpoint restore data for previous edits.", + priority: 20) + case "plans": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: saved plans", + consequence: "Clearing removes old plan-mode files.", + priority: 30) + case "debug": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: debug logs", + consequence: "Clearing removes past debug logs.", + priority: 40) + case "paste-cache", "image-cache": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: attachment cache", + consequence: "Clearing removes cached large pastes or attached images.", + priority: 50) + case "session-env": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: session metadata", + consequence: "Clearing removes per-session environment metadata.", + priority: 60) + case "shell-snapshots": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: shell snapshots", + consequence: "Clearing removes leftover runtime shell snapshot files.", + priority: 70) + case "todos": + self.make( + provider: .claude, + component: component, + title: "Manual cleanup: legacy todos", + consequence: "Clearing removes legacy per-session task lists.", + priority: 80) + default: + nil + } + } + + private static func codexRecommendation( + for component: ProviderStorageFootprint.Component, + roots: [String]) + -> ProviderStorageRecommendation? + { + guard self.path(component.path, isContainedIn: roots) else { return nil } + + return switch component.name { + case "sessions": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: sessions", + consequence: "Clearing removes past Codex session history.", + priority: 10) + case "archived_sessions": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: archived sessions", + consequence: "Clearing removes archived Codex session history.", + priority: 20) + case "cache", "caches", "Cache", "Caches": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: cache", + consequence: "Clearing removes provider-owned cached data.", + priority: 30) + case "log", "logs", "debug": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: logs", + consequence: "Clearing removes local diagnostic logs.", + priority: 40) + case let name where name.hasPrefix("logs_") && name.hasSuffix(".sqlite"): + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: logs", + consequence: "Clearing removes local diagnostic logs.", + priority: 40) + case "file-history": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: file history", + consequence: "Clearing removes local edit checkpoint history.", + priority: 50) + case "paste-cache", "image-cache", "session-env", "shell-snapshots", "shell_snapshots", "tmp", "temp", ".tmp": + self.make( + provider: .codex, + component: component, + title: "Manual cleanup: temporary data", + consequence: "Clearing removes local temporary provider data.", + priority: 60) + default: + nil + } + } + + private static func make( + provider: UsageProvider, + component: ProviderStorageFootprint.Component, + title: String, + consequence: String, + priority: Int) + -> ProviderStorageRecommendation + { + ProviderStorageRecommendation( + provider: provider, + path: component.path, + bytes: component.totalBytes, + title: title, + riskLevel: .manualCleanup, + consequence: consequence, + sortPriority: priority) + } + + private static func path(_ path: String, isContainedIn roots: [String]) -> Bool { + let standardizedPath = URL(fileURLWithPath: path).standardizedFileURL.path + return roots.contains { root in + let standardizedRoot = URL(fileURLWithPath: root, isDirectory: true).standardizedFileURL.path + return standardizedPath == standardizedRoot || standardizedPath.hasPrefix(standardizedRoot + "/") + } + } +} + +public enum ProviderStoragePathCatalog { + public static func candidatePaths( + for provider: UsageProvider, + environment: [String: String], + managedCodexAccounts: [ManagedCodexAccount] = [], + fileManager: FileManager = .default) + -> [String] + { + let home = fileManager.homeDirectoryForCurrentUser + + func homePath(_ relativePath: String) -> String { + home.appendingPathComponent(relativePath, isDirectory: true).path + } + + let candidates: [String] = switch provider { + case .codex: + [CodexHomeScope.ambientHomeURL(env: environment, fileManager: fileManager).path] + + managedCodexAccounts.map(\.managedHomePath) + case .claude: + [ + homePath(".claude"), + homePath(".config/claude"), + home + .appendingPathComponent("Library/Application Support/CodexBar/ClaudeProbe", isDirectory: true) + .path, + ] + case .gemini: + [ + homePath(".gemini"), + homePath(".config/gemini"), + ] + case .opencode, .opencodego: + [ + homePath(".config/opencode"), + ] + case .copilot: + [ + homePath(".config/github-copilot"), + ] + default: + [] + } + + return Self.uniqueStandardizedPaths(candidates) + } + + private static func uniqueStandardizedPaths(_ paths: [String]) -> [String] { + var seen: Set = [] + var result: [String] = [] + for path in paths { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let standardized = URL(fileURLWithPath: trimmed, isDirectory: true).standardizedFileURL.path + guard seen.insert(standardized).inserted else { continue } + result.append(standardized) + } + return result + } +} + +public struct ProviderStorageScanner: @unchecked Sendable { + private struct DirectoryScanResult { + var bytes: Int64 = 0 + var unreadablePaths: [String] = [] + var componentBytes: [String: Int64] = [:] + } + + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + public func scan( + provider: UsageProvider, + candidatePaths: [String], + now: Date = Date()) + -> ProviderStorageFootprint + { + var totalBytes: Int64 = 0 + var existingPaths: [String] = [] + var missingPaths: [String] = [] + var unreadablePaths: [String] = [] + var components: [ProviderStorageFootprint.Component] = [] + + for path in candidatePaths { + if Task.isCancelled { break } + var isDirectory: ObjCBool = false + guard self.fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { + missingPaths.append(path) + continue + } + + existingPaths.append(path) + let url = URL(fileURLWithPath: path, isDirectory: isDirectory.boolValue) + if self.isSymbolicLink(at: url) { + continue + } + if isDirectory.boolValue { + let result = self.scanDirectory(at: url) + if Task.isCancelled { break } + totalBytes += result.bytes + unreadablePaths.append(contentsOf: result.unreadablePaths) + components.append(contentsOf: result.componentBytes.map { + ProviderStorageFootprint.Component(path: $0.key, totalBytes: $0.value) + }) + } else { + let result = self.sizeOfFile(at: url) + totalBytes += result.bytes + unreadablePaths.append(contentsOf: result.unreadablePaths) + if result.bytes > 0 { + components.append(.init(path: url.path, totalBytes: result.bytes)) + } + } + } + + return ProviderStorageFootprint( + provider: provider, + totalBytes: totalBytes, + paths: existingPaths, + missingPaths: missingPaths, + unreadablePaths: unreadablePaths, + components: components.sorted { lhs, rhs in + if lhs.totalBytes == rhs.totalBytes { + return lhs.path.localizedCaseInsensitiveCompare(rhs.path) == .orderedAscending + } + return lhs.totalBytes > rhs.totalBytes + }, + updatedAt: now) + } + + private func isSymbolicLink(at url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } + + private func sizeOfFile(at url: URL) -> (bytes: Int64, unreadablePaths: [String]) { + if Task.isCancelled { return (0, []) } + let keys: Set = [ + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + ] + + guard let values = try? url.resourceValues(forKeys: keys) else { + return (0, [url.path]) + } + + if values.isSymbolicLink == true { + return (0, []) + } + + if values.isRegularFile == true { + return (Int64(values.fileSize ?? 0), []) + } + + return (0, []) + } + + private func scanDirectory(at url: URL) -> DirectoryScanResult { + if Task.isCancelled { return DirectoryScanResult() } + let keys: Set = [ + .isDirectoryKey, + .isRegularFileKey, + .isSymbolicLinkKey, + .fileSizeKey, + ] + + let unreadableCollector = ProviderStorageUnreadablePathCollector() + guard let enumerator = self.fileManager.enumerator( + at: url, + includingPropertiesForKeys: Array(keys), + options: [.skipsPackageDescendants], + errorHandler: { url, _ in + unreadableCollector.append(url.path) + return true + }) + else { + return DirectoryScanResult(unreadablePaths: [url.path]) + } + + var result = DirectoryScanResult() + let rootPath = url.standardizedFileURL.path + for case let itemURL as URL in enumerator { + if Task.isCancelled { + enumerator.skipDescendants() + break + } + guard let itemValues = try? itemURL.resourceValues(forKeys: keys) else { + unreadableCollector.append(itemURL.path) + continue + } + if itemValues.isSymbolicLink == true { + if itemValues.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + if itemValues.isRegularFile == true { + let bytes = Int64(itemValues.fileSize ?? 0) + result.bytes += bytes + if bytes > 0, let componentPath = self.topLevelComponentPath(for: itemURL, rootPath: rootPath) { + result.componentBytes[componentPath, default: 0] += bytes + } + } + } + result.unreadablePaths = unreadableCollector.paths + return result + } + + private func topLevelComponentPath(for url: URL, rootPath: String) -> String? { + let itemPath = url.standardizedFileURL.path + let pathPrefix = rootPath.hasSuffix("/") ? rootPath : "\(rootPath)/" + guard itemPath.hasPrefix(pathPrefix) else { return nil } + let suffix = itemPath.dropFirst(pathPrefix.count) + let relative = suffix.drop { $0 == "/" } + guard let first = relative.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true).first else { + return nil + } + return URL(fileURLWithPath: rootPath, isDirectory: true) + .appendingPathComponent(String(first)) + .path + } +} + +private final class ProviderStorageUnreadablePathCollector: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var paths: [String] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + + func append(_ path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(path) + } +} diff --git a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift index 1042d9f60..1c449d4fa 100644 --- a/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Abacus/AbacusUsageFetcher.swift @@ -224,19 +224,17 @@ public enum AbacusUsageFetcher { request.httpBody = Data("{}".utf8) } - let (data, response) = try await URLSession.shared.data(for: request) + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + let statusCode = response.statusCode - guard let httpResponse = response as? HTTPURLResponse else { - throw AbacusUsageError.networkError("Invalid response from \(url.lastPathComponent)") - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if statusCode == 401 || statusCode == 403 { throw AbacusUsageError.unauthorized } - guard httpResponse.statusCode == 200 else { + guard statusCode == 200 else { let body = String(data: data.prefix(200), encoding: .utf8) ?? "" - throw AbacusUsageError.networkError("HTTP \(httpResponse.statusCode): \(body)") + throw AbacusUsageError.networkError("HTTP \(statusCode): \(body)") } let parsed: Any diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift index c3d94e2ec..d86585a1f 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift @@ -27,7 +27,8 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable { public var dashboardURL: URL { switch self { case .international: - URL(string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=coding-plan#/efm/detail")! + URL( + string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=coding-plan#/efm/coding_plan")! case .chinaMainland: URL(string: "https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/coding_plan")! } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift index b15a40d54..b30863bd0 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift @@ -184,6 +184,8 @@ struct AlibabaCodingPlanWebFetchStrategy: ProviderFetchStrategy { return true case .invalidCredentials: return true + case .apiKeyUnavailableInRegion: + return false case let .apiError(message): return message.contains("HTTP 404") || message.contains("HTTP 403") case .networkError: diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanSettingsReader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanSettingsReader.swift index 51ce2df12..aa88d36ed 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanSettingsReader.swift @@ -2,6 +2,13 @@ import Foundation public struct AlibabaCodingPlanSettingsReader: Sendable { public static let apiTokenKey = "ALIBABA_CODING_PLAN_API_KEY" + public static let qwenAPITokenKey = "ALIBABA_QWEN_API_KEY" + public static let dashScopeAPITokenKey = "DASHSCOPE_API_KEY" + public static let apiTokenEnvironmentKeys = [ + Self.apiTokenKey, + Self.qwenAPITokenKey, + Self.dashScopeAPITokenKey, + ] public static let cookieHeaderKey = "ALIBABA_CODING_PLAN_COOKIE" public static let hostKey = "ALIBABA_CODING_PLAN_HOST" public static let quotaURLKey = "ALIBABA_CODING_PLAN_QUOTA_URL" @@ -9,7 +16,10 @@ public struct AlibabaCodingPlanSettingsReader: Sendable { public static func apiToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - self.cleaned(environment[self.apiTokenKey]) + for key in self.apiTokenEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return nil } public static func hostOverride( @@ -60,7 +70,8 @@ public enum AlibabaCodingPlanSettingsError: LocalizedError, Sendable { switch self { case .missingToken: return "Alibaba Coding Plan API key not found. " + - "Set apiKey in ~/.codexbar/config.json or ALIBABA_CODING_PLAN_API_KEY." + "Set apiKey in ~/.codexbar/config.json, ALIBABA_CODING_PLAN_API_KEY, " + + "ALIBABA_QWEN_API_KEY, or DASHSCOPE_API_KEY." case let .missingCookie(details): let base = "No Alibaba Coding Plan session cookies found in browsers. " + "If you use Safari, enable Full Disk Access for CodexBar/Terminal or paste a manual Cookie header." diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift index e3509d438..4eeaa2e5a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift @@ -108,18 +108,15 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { request.setValue(region.gatewayBaseURLString, forHTTPHeaderField: "Origin") request.setValue(region.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw AlibabaCodingPlanUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + 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 AlibabaCodingPlanUsageError.invalidCredentials } let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("Alibaba Coding Plan returned \(httpResponse.statusCode): \(body)") - throw AlibabaCodingPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + Self.log.error("Alibaba Coding Plan returned \(response.statusCode): \(body)") + throw AlibabaCodingPlanUsageError.apiError("HTTP \(response.statusCode)") } return try self.parseUsageSnapshot(from: data, now: now, authMode: .apiKey) @@ -158,18 +155,15 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { request.setValue(region.gatewayBaseURLString, forHTTPHeaderField: "Origin") request.setValue(region.consoleRefererURL.absoluteString, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw AlibabaCodingPlanUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + 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 AlibabaCodingPlanUsageError.loginRequired } let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("Alibaba Coding Plan returned \(httpResponse.statusCode): \(body)") - throw AlibabaCodingPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + Self.log.error("Alibaba Coding Plan returned \(response.statusCode): \(body)") + throw AlibabaCodingPlanUsageError.apiError("HTTP \(response.statusCode)") } return try self.parseUsageSnapshot(from: data, now: now, authMode: .webSession) @@ -338,10 +332,9 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") - if let (data, response) = try? await URLSession.shared.data(for: request), - let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200, - let html = String(data: data, encoding: .utf8), + if let response = try? await ProviderHTTPClient.shared.response(for: request), + response.statusCode == 200, + let html = String(data: response.data, encoding: .utf8), let token = self.extractConsoleSECToken(from: html), !token.isEmpty { @@ -404,12 +397,12 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { .absoluteString + "/" request.setValue(referer, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { return nil } - let object = try JSONSerialization.jsonObject(with: data, options: []) + let object = try JSONSerialization.jsonObject(with: response.data, options: []) let expanded = self.expandedJSON(object) return self.findFirstString(forKeys: ["secToken", "sec_token"], in: expanded) } @@ -480,9 +473,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { let normalizedCode = codeText.lowercased() if normalizedCode.contains("needlogin") || normalizedCode.contains("login") { if authMode == .apiKey { - throw AlibabaCodingPlanUsageError.apiError( - "This Alibaba endpoint requires a console session for this account/region. " + - "API key mode may be unavailable in CN on this endpoint.") + throw AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion } throw AlibabaCodingPlanUsageError.loginRequired } @@ -491,12 +482,16 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { let normalizedMessage = messageText.lowercased() if normalizedMessage.contains("log in") || normalizedMessage.contains("login") { if authMode == .apiKey { - throw AlibabaCodingPlanUsageError.apiError( - "This Alibaba endpoint requires a console session for this account/region. " + - "API key mode may be unavailable in CN on this endpoint.") + throw AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion } throw AlibabaCodingPlanUsageError.loginRequired } + if authMode == .apiKey, + normalizedMessage.contains("console session") || + normalizedMessage.contains("api key mode may be unavailable") + { + throw AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion + } } let instanceInfo = self.findActiveInstanceInfo(in: dictionary, now: now) @@ -1079,6 +1074,7 @@ public enum AlibabaCodingPlanUsageError: LocalizedError, Sendable, Equatable { case networkError(String) case apiError(String) case parseFailed(String) + case apiKeyUnavailableInRegion var shouldRetryOnAlternateRegion: Bool { switch self { @@ -1086,6 +1082,8 @@ public enum AlibabaCodingPlanUsageError: LocalizedError, Sendable, Equatable { true case .invalidCredentials: true + case .apiKeyUnavailableInRegion: + false case let .apiError(message): message.contains("HTTP 404") || message.contains("HTTP 403") case let .parseFailed(message): @@ -1099,9 +1097,13 @@ public enum AlibabaCodingPlanUsageError: LocalizedError, Sendable, Equatable { switch self { case .loginRequired: "Alibaba Coding Plan console login is required. " + - "Sign in to Model Studio in a supported browser or paste a Cookie header." + "Sign in to Model Studio/Bailian in a supported browser or paste a Cookie header." case .invalidCredentials: "Alibaba Coding Plan API credentials are invalid or expired." + case .apiKeyUnavailableInRegion: + "Alibaba Coding Plan quota is not available through Coding Plan API keys for this account/region. " + + "Use cookie authentication in Settings -> Providers -> Alibaba if the Alibaba console exposes a " + + "compatible session, or switch regions if quota API access is available there." case let .networkError(message): "Alibaba Coding Plan network error: \(message)" case let .apiError(message): diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index bd6c62f6e..dbe39d4c8 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -249,13 +249,10 @@ public struct AmpUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = URLSession(configuration: .ephemeral, delegate: diagnostics, delegateQueue: nil) - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw AmpUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: request) let responseInfo = ResponseInfo( statusCode: httpResponse.statusCode, - url: httpResponse.url?.absoluteString ?? "unknown") + url: httpResponse.response.url?.absoluteString ?? "unknown") guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { @@ -267,7 +264,7 @@ public struct AmpUsageFetcher: Sendable { throw AmpUsageError.networkError("HTTP \(httpResponse.statusCode)") } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: httpResponse.data, encoding: .utf8) ?? "" return (html, responseInfo) } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift new file mode 100644 index 000000000..de75248d2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift @@ -0,0 +1,288 @@ +import Foundation + +public struct AntigravityOAuthCredentials: Codable, Sendable, Equatable { + public var accessToken: String? + public var refreshToken: String? + public var expiryDateMilliseconds: Double? + public var idToken: String? + public var email: String? + public var projectID: String? + public var clientID: String? + public var clientSecret: String? + + public init( + accessToken: String?, + refreshToken: String?, + expiryDate: Date?, + idToken: String? = nil, + email: String? = nil, + projectID: String? = nil, + clientID: String? = nil, + clientSecret: String? = nil) + { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiryDateMilliseconds = expiryDate.map { $0.timeIntervalSince1970 * 1000 } + self.idToken = idToken + self.email = email + self.projectID = projectID + self.clientID = clientID + self.clientSecret = clientSecret + } + + public var expiryDate: Date? { + guard let expiryDateMilliseconds else { return nil } + return Date(timeIntervalSince1970: expiryDateMilliseconds / 1000) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accessToken = + try container.decodeIfPresent(String.self, forKey: .accessTokenSnake) + ?? container.decodeIfPresent(String.self, forKey: .accessTokenCamel) + self.refreshToken = + try container.decodeIfPresent(String.self, forKey: .refreshTokenSnake) + ?? container.decodeIfPresent(String.self, forKey: .refreshTokenCamel) + self.idToken = + try container.decodeIfPresent(String.self, forKey: .idTokenSnake) + ?? container.decodeIfPresent(String.self, forKey: .idTokenCamel) + self.email = try container.decodeIfPresent(String.self, forKey: .email) + self.projectID = + try container.decodeIfPresent(String.self, forKey: .projectIDSnake) + ?? container.decodeIfPresent(String.self, forKey: .projectIDCamel) + self.clientID = + try container.decodeIfPresent(String.self, forKey: .clientIDSnake) + ?? container.decodeIfPresent(String.self, forKey: .clientIDCamel) + self.clientSecret = + try container.decodeIfPresent(String.self, forKey: .clientSecretSnake) + ?? container.decodeIfPresent(String.self, forKey: .clientSecretCamel) + + if let expiryDateMilliseconds = try container.decodeIfPresent(Double.self, forKey: .expiryDateSnake) + ?? container.decodeIfPresent(Double.self, forKey: .expiresAtCamel) + { + self.expiryDateMilliseconds = expiryDateMilliseconds + } else if let expiryDateMilliseconds = try container.decodeIfPresent(Int.self, forKey: .expiryDateSnake) + ?? container.decodeIfPresent(Int.self, forKey: .expiresAtCamel) + { + self.expiryDateMilliseconds = Double(expiryDateMilliseconds) + } else { + self.expiryDateMilliseconds = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.accessToken, forKey: .accessTokenSnake) + try container.encodeIfPresent(self.refreshToken, forKey: .refreshTokenSnake) + try container.encodeIfPresent(self.expiryDateMilliseconds, forKey: .expiryDateSnake) + try container.encodeIfPresent(self.idToken, forKey: .idTokenSnake) + try container.encodeIfPresent(self.email, forKey: .email) + try container.encodeIfPresent(self.projectID, forKey: .projectIDSnake) + try container.encodeIfPresent(self.clientID, forKey: .clientIDSnake) + try container.encodeIfPresent(self.clientSecret, forKey: .clientSecretSnake) + } + + enum CodingKeys: String, CodingKey { + case accessTokenSnake = "access_token" + case accessTokenCamel = "accessToken" + case refreshTokenSnake = "refresh_token" + case refreshTokenCamel = "refreshToken" + case expiryDateSnake = "expiry_date" + case expiresAtCamel = "expiresAt" + case idTokenSnake = "id_token" + case idTokenCamel = "idToken" + case email + case projectIDSnake = "project_id" + case projectIDCamel = "projectId" + case clientIDSnake = "client_id" + case clientIDCamel = "clientId" + case clientSecretSnake = "client_secret" + case clientSecretCamel = "clientSecret" + } +} + +public struct AntigravityOAuthClient: Sendable, Equatable { + public let clientID: String + public let clientSecret: String + + public init(clientID: String, clientSecret: String) { + self.clientID = clientID + self.clientSecret = clientSecret + } +} + +public enum AntigravityOAuthConfig { + public static var configuredClientID: String? { + let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_ID"] + return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + public static var configuredClientSecret: String? { + let value = ProcessInfo.processInfo.environment["ANTIGRAVITY_OAUTH_CLIENT_SECRET"] + return value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + public static let authURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! + public static let tokenURL = URL(string: "https://oauth2.googleapis.com/token")! + public static let userInfoURL = URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")! + public static let scopes = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + ] + + public static let missingCredentialsMessage = + """ + Antigravity OAuth client is not configured. Install Antigravity.app or set \ + ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET before logging in. + """ + + public static func resolvedClient() -> AntigravityOAuthClient? { + if let client = environmentClient() { + return client + } + return Self.discoverClientFromInstalledApp() + } + + private static func environmentClient() -> AntigravityOAuthClient? { + guard let clientID = configuredClientID, + let clientSecret = configuredClientSecret + else { + return nil + } + return AntigravityOAuthClient(clientID: clientID, clientSecret: clientSecret) + } + + private static func discoverClientFromInstalledApp(fileManager: FileManager = .default) -> AntigravityOAuthClient? { + for url in self.candidateAppMainJSURLs(fileManager: fileManager) + where fileManager.fileExists(atPath: url.path) + { + guard let content = try? String(contentsOf: url, encoding: .utf8), + let client = Self.parseClient(fromMainJS: content) + else { + continue + } + return client + } + return nil + } + + private static func candidateAppMainJSURLs(fileManager: FileManager) -> [URL] { + let bundleRelativePath = "Antigravity.app/Contents/Resources/app/out/main.js" + return [ + URL(fileURLWithPath: "/Applications", isDirectory: true).appendingPathComponent(bundleRelativePath), + fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Applications", isDirectory: true) + .appendingPathComponent(bundleRelativePath), + ] + } + + private static func parseClient(fromMainJS content: String) -> AntigravityOAuthClient? { + let marker = "vs/platform/cloudCode/common/oauthClient.js" + let searchStart = content.range(of: marker)?.lowerBound ?? content.startIndex + let searchEnd = content.index(searchStart, offsetBy: 4000, limitedBy: content.endIndex) ?? content.endIndex + let haystack = String(content[searchStart.. String? { + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(text.startIndex.. AntigravityOAuthCredentials? { + guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return nil } + let data = try Data(contentsOf: self.fileURL) + return try JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data) + } + + public func save(_ credentials: AntigravityOAuthCredentials) throws { + let data = try JSONEncoder.antigravityCredentials.encode(credentials) + let directory = self.fileURL.deletingLastPathComponent() + if !self.fileManager.fileExists(atPath: directory.path) { + try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + try data.write(to: self.fileURL, options: [.atomic]) + try self.applySecurePermissionsIfNeeded() + } + + public func deleteIfPresent() throws { + guard self.fileManager.fileExists(atPath: self.fileURL.path) else { return } + try self.fileManager.removeItem(at: self.fileURL) + } + + public static func defaultDirectoryURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + home + .appendingPathComponent(".codexbar", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + + public static func defaultURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + self.defaultDirectoryURL(home: home) + .appendingPathComponent("oauth_creds.json") + } + + public static func tokenAccountValue(for credentials: AntigravityOAuthCredentials) throws -> String { + let data = try JSONEncoder.antigravityCredentials.encode(credentials) + guard let value = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return value + } + + public static func credentials(fromTokenAccountValue value: String) -> AntigravityOAuthCredentials? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data) + } + + private func applySecurePermissionsIfNeeded() throws { + #if os(macOS) || os(Linux) + try self.fileManager.setAttributes([ + .posixPermissions: NSNumber(value: Int16(0o600)), + ], ofItemAtPath: self.fileURL.path) + #endif + } +} + +extension JSONEncoder { + fileprivate static let antigravityCredentials: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 1e59964b0..27aeb8100 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -33,12 +33,28 @@ public enum AntigravityProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Antigravity cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .cli], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AntigravityStatusFetchStrategy()] })), + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "antigravity", versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + let local = AntigravityStatusFetchStrategy() + let oauth = AntigravityOAuthFetchStrategy() + + switch context.sourceMode { + case .cli: + return [local] + case .oauth: + return [oauth] + case .auto: + return [local, oauth] + case .web, .api: + return [] + } + } } struct AntigravityStatusFetchStrategy: ProviderFetchStrategy { @@ -58,6 +74,51 @@ struct AntigravityStatusFetchStrategy: ProviderFetchStrategy { sourceLabel: "local") } + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} + +struct AntigravityOAuthFetchStrategy: ProviderFetchStrategy { + let id: String = "antigravity.oauth" + let kind: ProviderFetchKind = .oauth + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = AntigravityRemoteUsageFetcher( + environment: context.env, + credentialsUpdateHandler: { credentials in + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + return + } + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + await updater(.antigravity, accountID, token) + }) + let snapshot = try await fetcher.fetch() + let usage = if snapshot.modelQuotas.isEmpty { + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: snapshot.accountEmail, + accountOrganization: nil, + loginMethod: snapshot.accountPlan)) + } else { + try snapshot.toUsageSnapshot() + } + return self.makeResult( + usage: usage, + sourceLabel: "oauth") + } + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift new file mode 100644 index 000000000..0bdb4141a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -0,0 +1,704 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum AntigravityRemoteFetchError: LocalizedError, Sendable, Equatable { + case notLoggedIn + case permissionDenied(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notLoggedIn: + "Antigravity Google auth not found. Use Antigravity login to authenticate." + case let .permissionDenied(message): + "Antigravity remote API permission denied: \(message)" + case let .apiError(message): + "Antigravity remote API error: \(message)" + case let .parseFailed(message): + "Could not parse Antigravity remote usage: \(message)" + } + } +} + +public struct AntigravityRemoteUsageFetcher: Sendable { + public var timeout: TimeInterval = 10.0 + public var homeDirectory: String + public var environment: [String: String] + public var dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) + public var oauthClientResolver: @Sendable () -> AntigravityOAuthClient? + public var credentialsUpdateHandler: @Sendable (AntigravityOAuthCredentials) async throws -> Void + + private static let log = CodexBarLog.logger(LogCategories.antigravity) + private static let userAgent = "antigravity" + private static let baseURL = "https://cloudcode-pa.googleapis.com" + private static let loadCodeAssistEndpoint = "\(baseURL)/v1internal:loadCodeAssist" + private static let onboardUserEndpoint = "\(baseURL)/v1internal:onboardUser" + private static let fetchAvailableModelsEndpoint = "\(baseURL)/v1internal:fetchAvailableModels" + private static let retrieveUserQuotaEndpoint = "\(baseURL)/v1internal:retrieveUserQuota" + private static let refreshSafetyWindow: TimeInterval = 60 + + private struct FetchContext { + let timeout: TimeInterval + let store: AntigravityOAuthCredentialsStore? + let dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) + let oauthClientResolver: @Sendable () -> AntigravityOAuthClient? + let credentialsUpdateHandler: @Sendable (AntigravityOAuthCredentials) async throws -> Void + + func persistCredentials(_ credentials: AntigravityOAuthCredentials) async throws { + if let store { + try store.save(credentials) + } else { + try await self.credentialsUpdateHandler(credentials) + } + } + } + + public init( + timeout: TimeInterval = 10.0, + homeDirectory: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in + try await ProviderHTTPClient.shared.data(for: request) + }, + oauthClientResolver: @escaping @Sendable () -> AntigravityOAuthClient? = { + AntigravityOAuthConfig.resolvedClient() + }, + credentialsUpdateHandler: @escaping @Sendable (AntigravityOAuthCredentials) async throws -> Void = { _ in }) + { + self.timeout = timeout + self.homeDirectory = homeDirectory + self.environment = environment + self.dataLoader = dataLoader + self.oauthClientResolver = oauthClientResolver + self.credentialsUpdateHandler = credentialsUpdateHandler + } + + public func fetch() async throws -> AntigravityStatusSnapshot { + let source = try Self.resolveCredentialSource(homeDirectory: self.homeDirectory, environment: self.environment) + guard let credentials = source.credentials else { + throw AntigravityRemoteFetchError.notLoggedIn + } + return try await self.fetchSnapshot( + using: credentials, + store: source.store) + } + + private func fetchSnapshot( + using initialCredentials: AntigravityOAuthCredentials, + store: AntigravityOAuthCredentialsStore?) async throws + -> AntigravityStatusSnapshot + { + guard let storedAccessToken = initialCredentials.accessToken?.trimmedNonEmpty else { + throw AntigravityRemoteFetchError.notLoggedIn + } + + var credentials = initialCredentials + var accessToken = storedAccessToken + let context = FetchContext( + timeout: self.timeout, + store: store, + dataLoader: self.dataLoader, + oauthClientResolver: self.oauthClientResolver, + credentialsUpdateHandler: self.credentialsUpdateHandler) + if Self.shouldRefresh(expiryDate: credentials.expiryDate, now: Date()) { + guard let refreshToken = credentials.refreshToken?.trimmedNonEmpty else { + throw AntigravityRemoteFetchError.notLoggedIn + } + let refreshed = try await Self.refreshAccessToken( + credentials: credentials, + refreshToken: refreshToken, + context: context) + accessToken = refreshed.accessToken + credentials = refreshed.credentials + if let store { + credentials = try store.load() ?? credentials + } + credentials.accessToken = credentials.accessToken?.trimmedNonEmpty ?? accessToken + } + + let claims = Self.extractClaims(from: credentials) + let codeAssist = try await Self.loadCodeAssist( + accessToken: accessToken, + timeout: self.timeout, + dataLoader: self.dataLoader) + let projectId = try await Self.resolveProjectID( + accessToken: accessToken, + storedProjectID: credentials.projectID?.trimmedNonEmpty, + initialResponse: codeAssist, + context: context) + if let projectId, credentials.projectID?.trimmedNonEmpty != projectId { + credentials.projectID = projectId + do { + try await context.persistCredentials(credentials) + } catch { + Self.log.warning("Could not persist Antigravity project ID: \(error.localizedDescription)") + } + } + let models = try await Self.fetchModelQuotas( + accessToken: accessToken, + projectId: projectId, + timeout: self.timeout, + dataLoader: self.dataLoader) + + return AntigravityStatusSnapshot( + modelQuotas: models, + accountEmail: claims.email, + accountPlan: Self.resolvePlan(response: codeAssist, claims: claims)) + } + + private static func shouldRefresh(expiryDate: Date?, now: Date) -> Bool { + guard let expiryDate else { return false } + return expiryDate.timeIntervalSince(now) <= Self.refreshSafetyWindow + } + + private static func loadCodeAssist( + accessToken: String, + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> CodeAssistResponse + { + let body = [ + "metadata": [ + "ideType": "ANTIGRAVITY", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + ], + ] + return try await Self.sendRequest( + endpoint: Self.loadCodeAssistEndpoint, + accessToken: accessToken, + body: body, + timeout: timeout, + dataLoader: dataLoader) + } + + private static func fetchAvailableModels( + accessToken: String, + projectId: String?, + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> FetchAvailableModelsResponse + { + let body: [String: Any] = if let projectId = projectId?.trimmedNonEmpty { + ["project": projectId] + } else { + [:] + } + return try await Self.sendRequest( + endpoint: Self.fetchAvailableModelsEndpoint, + accessToken: accessToken, + body: body, + timeout: timeout, + dataLoader: dataLoader) + } + + private static func fetchModelQuotas( + accessToken: String, + projectId: String?, + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> [AntigravityModelQuota] + { + do { + let response = try await Self.fetchAvailableModels( + accessToken: accessToken, + projectId: projectId, + timeout: timeout, + dataLoader: dataLoader) + return try Self.parseModelQuotas(response) + } catch let error as AntigravityRemoteFetchError { + guard case .permissionDenied = error else { + throw error + } + Self.log.info("Falling back to retrieveUserQuota for Antigravity remote usage") + do { + let response = try await Self.retrieveUserQuota( + accessToken: accessToken, + projectId: projectId, + timeout: timeout, + dataLoader: dataLoader) + return try Self.parseQuotaBuckets(response) + } catch let quotaError as AntigravityRemoteFetchError { + guard case .permissionDenied = quotaError else { + throw quotaError + } + Self.log.info("Antigravity remote quota endpoints are not permitted for this account") + return [] + } + } + } + + private static func retrieveUserQuota( + accessToken: String, + projectId: String?, + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> RetrieveUserQuotaResponse + { + let body: [String: Any] = if let projectId = projectId?.trimmedNonEmpty { + ["project": projectId] + } else { + [:] + } + return try await Self.sendRequest( + endpoint: Self.retrieveUserQuotaEndpoint, + accessToken: accessToken, + body: body, + timeout: timeout, + dataLoader: dataLoader) + } + + private static func resolveProjectID( + accessToken: String, + storedProjectID: String?, + initialResponse: CodeAssistResponse, + context: FetchContext) async throws + -> String? + { + if let storedProjectID { + return storedProjectID + } + + if let projectID = initialResponse.projectID { + return projectID + } + + guard let tierID = Self.pickOnboardTier(from: initialResponse) else { + return nil + } + + let onboardBody: [String: Any] = [ + "tierId": tierID, + "metadata": [ + "ideType": "ANTIGRAVITY", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + ], + ] + + do { + let onboardResponse: OnboardResponse = try await Self.sendRequest( + endpoint: Self.onboardUserEndpoint, + accessToken: accessToken, + body: onboardBody, + timeout: context.timeout, + dataLoader: context.dataLoader) + if let projectID = onboardResponse.projectID { + return projectID + } + } catch { + Self.log.warning("Antigravity onboarding request failed", metadata: [ + "error": "\(error.localizedDescription)", + ]) + } + + for _ in 0..<5 { + try? await Task.sleep(for: .milliseconds(2000)) + let refreshed = try await Self.loadCodeAssist( + accessToken: accessToken, + timeout: context.timeout, + dataLoader: context.dataLoader) + if let projectID = refreshed.projectID { + return projectID + } + } + + return nil + } + + private static func sendRequest( + endpoint: String, + accessToken: String, + body: [String: Any], + timeout: TimeInterval, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) async throws + -> Response + { + guard let url = URL(string: endpoint) else { + throw AntigravityRemoteFetchError.apiError("Invalid endpoint URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let httpResponse = try await ProviderHTTPTransportHandler(dataLoader).response(for: request) + + switch httpResponse.statusCode { + case 200: + break + case 401: + throw AntigravityRemoteFetchError.notLoggedIn + case 403: + let message = String(data: httpResponse.data, encoding: .utf8)?.trimmedNonEmpty ?? "HTTP 403" + throw AntigravityRemoteFetchError.permissionDenied(message) + default: + let message = String(data: httpResponse.data, encoding: .utf8)?.trimmedNonEmpty + ?? "HTTP \(httpResponse.statusCode)" + throw AntigravityRemoteFetchError.apiError("HTTP \(httpResponse.statusCode): \(message)") + } + + do { + return try JSONDecoder().decode(Response.self, from: httpResponse.data) + } catch { + throw AntigravityRemoteFetchError.parseFailed(error.localizedDescription) + } + } + + private static func parseModelQuotas(_ response: FetchAvailableModelsResponse) throws -> [AntigravityModelQuota] { + let models = response.models ?? [:] + return models.compactMap { modelID, model in + guard let quotaInfo = model.quotaInfo else { return nil } + let resetTime = quotaInfo.resetTime.flatMap(Self.parseResetTime(_:)) + let label = model.displayName?.trimmedNonEmpty + ?? model.label?.trimmedNonEmpty + ?? modelID + return AntigravityModelQuota( + label: label, + modelId: modelID, + remainingFraction: quotaInfo.remainingFraction, + resetTime: resetTime, + resetDescription: resetTime.map { UsageFormatter.resetDescription(from: $0) }) + } + } + + private static func parseQuotaBuckets(_ response: RetrieveUserQuotaResponse) throws -> [AntigravityModelQuota] { + guard let buckets = response.buckets, !buckets.isEmpty else { + throw AntigravityRemoteFetchError.parseFailed("No quota buckets in response") + } + + var modelQuotaMap: [String: (fraction: Double?, resetTime: String?)] = [:] + for bucket in buckets { + guard let modelID = bucket.modelId?.trimmedNonEmpty else { continue } + let next = (bucket.remainingFraction, bucket.resetTime) + if let existing = modelQuotaMap[modelID] { + let existingValue = existing.fraction ?? Double.greatestFiniteMagnitude + let nextValue = next.0 ?? Double.greatestFiniteMagnitude + if nextValue < existingValue { + modelQuotaMap[modelID] = next + } + } else { + modelQuotaMap[modelID] = next + } + } + + return modelQuotaMap.keys.sorted().compactMap { modelID in + guard let info = modelQuotaMap[modelID] else { return nil } + let resetTime = info.resetTime.flatMap(Self.parseResetTime(_:)) + return AntigravityModelQuota( + label: modelID, + modelId: modelID, + remainingFraction: info.fraction, + resetTime: resetTime, + resetDescription: resetTime.map { UsageFormatter.resetDescription(from: $0) }) + } + } + + private static func resolvePlan(response: CodeAssistResponse, claims: TokenClaims) -> String? { + if let planType = response.planInfo?.planType?.trimmedNonEmpty { + return planType + } + + switch (response.currentTier?.id?.trimmedNonEmpty, claims.hostedDomain) { + case ("standard-tier", _): + return "Paid" + case ("free-tier", .some): + return "Workspace" + case ("free-tier", .none): + return "Free" + case ("legacy-tier", _): + return "Legacy" + default: + return response.currentTier?.name?.trimmedNonEmpty + } + } + + private static func pickOnboardTier(from response: CodeAssistResponse) -> String? { + if let defaultTier = response.allowedTiers? + .first(where: { $0.isDefault == true && $0.id?.trimmedNonEmpty != nil })?.id?.trimmedNonEmpty + { + return defaultTier + } + if let firstTier = response.allowedTiers? + .first(where: { $0.id?.trimmedNonEmpty != nil })?.id?.trimmedNonEmpty + { + return firstTier + } + if let paidTier = response.paidTier?.id?.trimmedNonEmpty { + return paidTier + } + if let currentTier = response.currentTier?.id?.trimmedNonEmpty { + return currentTier + } + return nil + } + + private static func parseResetTime(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + private static func credentialsStore(homeDirectory: String) -> AntigravityOAuthCredentialsStore { + let homeURL = URL(fileURLWithPath: homeDirectory, isDirectory: true) + return AntigravityOAuthCredentialsStore(fileURL: AntigravityOAuthCredentialsStore.defaultURL(home: homeURL)) + } + + private static func resolveCredentialSource( + homeDirectory: String, + environment: [String: String]) throws -> ( + credentials: AntigravityOAuthCredentials?, + store: AntigravityOAuthCredentialsStore?) + { + let primaryStore = Self.credentialsStore(homeDirectory: homeDirectory) + if let tokenValue = environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey] { + guard let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: tokenValue) + else { + throw AntigravityRemoteFetchError.parseFailed("Could not decode selected account credentials.") + } + return (credentials, nil) + } + return try (primaryStore.load(), primaryStore) + } + + private struct RefreshResult { + let accessToken: String + let credentials: AntigravityOAuthCredentials + } + + private static func refreshAccessToken( + credentials: AntigravityOAuthCredentials, + refreshToken: String, + context: FetchContext) async throws + -> RefreshResult + { + let oauthClient = try Self.refreshOAuthClient( + from: credentials, + oauthClientResolver: context.oauthClientResolver) + + var request = URLRequest(url: AntigravityOAuthConfig.tokenURL) + request.httpMethod = "POST" + request.timeoutInterval = context.timeout + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = Self.formBody([ + "client_id": oauthClient.clientID, + "client_secret": oauthClient.clientSecret, + "refresh_token": refreshToken, + "grant_type": "refresh_token", + ]) + + let httpResponse = try await ProviderHTTPTransportHandler(context.dataLoader).response(for: request) + guard httpResponse.statusCode == 200 else { + throw AntigravityRemoteFetchError.notLoggedIn + } + guard let json = try JSONSerialization.jsonObject(with: httpResponse.data) as? [String: Any], + let accessToken = json["access_token"] as? String + else { + throw AntigravityRemoteFetchError.parseFailed("Could not parse refresh response") + } + + let updatedCredentials = Self.updatedCredentials(credentials, refreshResponse: json) + try await context.persistCredentials(updatedCredentials) + return RefreshResult(accessToken: accessToken, credentials: updatedCredentials) + } + + private static func refreshOAuthClient( + from credentials: AntigravityOAuthCredentials, + oauthClientResolver: @escaping @Sendable () -> AntigravityOAuthClient?) throws + -> AntigravityOAuthClient + { + if let clientID = credentials.clientID?.trimmedNonEmpty, + let clientSecret = credentials.clientSecret?.trimmedNonEmpty + { + return AntigravityOAuthClient(clientID: clientID, clientSecret: clientSecret) + } + + guard let client = oauthClientResolver() else { + throw AntigravityRemoteFetchError.apiError(AntigravityOAuthConfig.missingCredentialsMessage) + } + return client + } + + private static func updatedCredentials( + _ credentials: AntigravityOAuthCredentials, + refreshResponse: [String: Any]) -> AntigravityOAuthCredentials + { + var credentials = credentials + if let accessToken = refreshResponse["access_token"] as? String { + credentials.accessToken = accessToken + } + if let expiresIn = refreshResponse["expires_in"] as? Double { + credentials.expiryDateMilliseconds = (Date().timeIntervalSince1970 + expiresIn) * 1000 + } + if let expiresIn = refreshResponse["expires_in"] as? Int { + credentials.expiryDateMilliseconds = (Date().timeIntervalSince1970 + Double(expiresIn)) * 1000 + } + if let idToken = refreshResponse["id_token"] as? String { + credentials.idToken = idToken + } + return credentials + } + + private static func formBody(_ values: [String: String]) -> Data? { + var components = URLComponents() + components.queryItems = values.map { key, value in + URLQueryItem(name: key, value: value) + } + return components.query?.data(using: .utf8) + } + + private struct TokenClaims { + let email: String? + let hostedDomain: String? + } + + private static func extractClaims(from credentials: AntigravityOAuthCredentials) -> TokenClaims { + let tokenClaims = Self.extractClaimsFromToken(credentials.idToken) + return TokenClaims( + email: tokenClaims.email ?? credentials.email?.trimmedNonEmpty, + hostedDomain: tokenClaims.hostedDomain) + } + + private static func extractClaimsFromToken(_ idToken: String?) -> TokenClaims { + guard let idToken else { + return TokenClaims(email: nil, hostedDomain: nil) + } + + let parts = idToken.components(separatedBy: ".") + guard parts.count >= 2 else { + return TokenClaims(email: nil, hostedDomain: nil) + } + + var payload = parts[1] + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = payload.count % 4 + if remainder > 0 { + payload += String(repeating: "=", count: 4 - remainder) + } + + guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return TokenClaims(email: nil, hostedDomain: nil) + } + + return TokenClaims( + email: (json["email"] as? String)?.trimmedNonEmpty, + hostedDomain: (json["hd"] as? String)?.trimmedNonEmpty) + } +} + +extension String { + fileprivate var trimmedNonEmpty: String? { + let trimmed = self.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +private struct ProjectReference: Decodable { + let value: String? + + init(from decoder: Decoder) throws { + let single = try decoder.singleValueContainer() + if let stringValue = try? single.decode(String.self) { + self.value = stringValue + return + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.value = try keyed.decodeIfPresent(String.self, forKey: .id) + ?? keyed.decodeIfPresent(String.self, forKey: .projectID) + } + + private enum CodingKeys: String, CodingKey { + case id + case projectID = "projectId" + } +} + +private struct CodeAssistResponse: Decodable { + let planInfo: CodeAssistPlanInfo? + let currentTier: TierInfo? + let paidTier: TierInfo? + let allowedTiers: [AllowedTier]? + let cloudaicompanionProject: ProjectReference? + + var projectID: String? { + self.cloudaicompanionProject?.value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} + +private struct CodeAssistPlanInfo: Decodable { + let planType: String? +} + +private struct TierInfo: Decodable { + let id: String? + let name: String? +} + +private struct AllowedTier: Decodable { + let id: String? + let isDefault: Bool? +} + +private struct OnboardResponse: Decodable { + let response: OnboardInnerResponse? + + var projectID: String? { + self.response?.cloudaicompanionProject?.value?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} + +private struct OnboardInnerResponse: Decodable { + let cloudaicompanionProject: ProjectReference? +} + +private struct FetchAvailableModelsResponse: Decodable { + let models: [String: AntigravityRemoteModel]? +} + +private struct RetrieveUserQuotaResponse: Decodable { + let buckets: [RetrieveUserQuotaBucket]? +} + +private struct RetrieveUserQuotaBucket: Decodable { + let modelId: String? + let remainingFraction: Double? + let resetTime: String? +} + +private struct AntigravityRemoteModel: Decodable { + let displayName: String? + let label: String? + let quotaInfo: AntigravityRemoteQuotaInfo? +} + +private struct AntigravityRemoteQuotaInfo: Decodable { + let remainingFraction: Double? + let resetTime: String? +} + +extension String? { + fileprivate var trimmedNonEmpty: String? { + self?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 22f2600a0..7cb8359e3 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -145,17 +145,29 @@ public struct AntigravityStatusSnapshot: Sendable { let candidates = models.filter { $0.family == family && $0.selectionPriority != nil } guard !candidates.isEmpty else { return nil } return candidates.min { lhs, rhs in + let lhsHasRemainingFraction = lhs.quota.remainingFraction != nil + let rhsHasRemainingFraction = rhs.quota.remainingFraction != nil + if lhsHasRemainingFraction != rhsHasRemainingFraction { + return lhsHasRemainingFraction && !rhsHasRemainingFraction + } let lhsPriority = lhs.selectionPriority ?? Int.max let rhsPriority = rhs.selectionPriority ?? Int.max if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } - let lhsHasRemainingFraction = lhs.quota.remainingFraction != nil - let rhsHasRemainingFraction = rhs.quota.remainingFraction != nil - if lhsHasRemainingFraction != rhsHasRemainingFraction { - return lhsHasRemainingFraction && !rhsHasRemainingFraction + if lhs.quota.remainingPercent != rhs.quota.remainingPercent { + return lhs.quota.remainingPercent < rhs.quota.remainingPercent + } + switch (lhs.quota.resetTime, rhs.quota.resetTime) { + case let (.some(left), .some(right)) where left != right: + return left < right + case (.some, .none): + return true + case (.none, .some): + return false + default: + return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending } - return lhs.quota.remainingPercent < rhs.quota.remainingPercent }?.quota } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityUsageDataSource.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityUsageDataSource.swift new file mode 100644 index 000000000..7db69bf62 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityUsageDataSource.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum AntigravityUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case oauth + case cli + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .oauth: "Google OAuth" + case .cli: "Local IDE API" + } + } + + public var sourceLabel: String { + switch self { + case .auto: + "auto" + case .oauth: + "oauth" + case .cli: + "cli" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 9485c8ef7..40e1974a9 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -59,9 +59,15 @@ public final class AugmentSessionKeepalive { } self.log("🚀 Starting Augment session keepalive") - self.log(" - Check interval: \(Int(self.checkInterval))s (every 5 minutes)") - self.log(" - Refresh buffer: \(Int(self.refreshBufferSeconds))s (5 minutes before expiry)") - self.log(" - Min refresh interval: \(Int(self.minRefreshInterval))s (2 minutes)") + self.log( + " - Check interval: \(Int(self.checkInterval))s " + + "(\(Self.durationDescription(seconds: self.checkInterval)))") + self.log( + " - Refresh buffer: \(Int(self.refreshBufferSeconds))s " + + "(\(Self.durationDescription(seconds: self.refreshBufferSeconds)) before expiry)") + self.log( + " - Min refresh interval: \(Int(self.minRefreshInterval))s " + + "(\(Self.durationDescription(seconds: self.minRefreshInterval)))") self.timerTask = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { @@ -371,7 +377,7 @@ public final class AugmentSessionKeepalive { request.setValue("https://app.augmentcode.com", forHTTPHeaderField: "Referer") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { self.log(" ✗ Invalid response type") @@ -435,6 +441,15 @@ public final class AugmentSessionKeepalive { private static let log = CodexBarLog.logger(LogCategories.augmentKeepalive) + private static func durationDescription(seconds: TimeInterval) -> String { + let totalSeconds = max(0, Int(seconds.rounded())) + if totalSeconds >= 60, totalSeconds % 60 == 0 { + let minutes = totalSeconds / 60 + return "\(minutes) minute\(minutes == 1 ? "" : "s")" + } + return "\(totalSeconds) second\(totalSeconds == 1 ? "" : "s")" + } + private func log(_ message: String) { let timestamp = Date().formatted(date: .omitted, time: .standard) let fullMessage = "[\(timestamp)] [AugmentKeepalive] \(message)" diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index 60ceb0b15..cff8957a4 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -131,6 +131,10 @@ public struct AugmentCreditsResponse: Codable, Sendable { } public var creditsLimit: Double? { + if let available = self.usageUnitsAvailable, available > 0 { + return available + } + guard let remaining = self.usageUnitsRemaining, let consumed = self.usageUnitsConsumedThisBillingCycle else { @@ -222,6 +226,7 @@ public struct AugmentStatusSnapshot: Sendable { private static func formatResetDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .full return formatter.localizedString(for: date, relativeTo: Date()) } @@ -492,7 +497,7 @@ public struct AugmentStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") @@ -530,7 +535,7 @@ public struct AugmentStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift new file mode 100644 index 000000000..360ac9ee0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift @@ -0,0 +1,179 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +enum BedrockAWSSigner { + struct Credentials { + let accessKeyID: String + let secretAccessKey: String + let sessionToken: String? + } + + static func sign( + request: inout URLRequest, + credentials: Credentials, + region: String, + service: String, + date: Date = Date()) + { + let dateFormatter = Self.dateFormatter() + let dateStamp = Self.dateStamp(date: date) + let amzDate = dateFormatter.string(from: date) + + request.setValue(amzDate, forHTTPHeaderField: "X-Amz-Date") + if let sessionToken = credentials.sessionToken { + request.setValue(sessionToken, forHTTPHeaderField: "X-Amz-Security-Token") + } + + let host = request.url?.host ?? "" + request.setValue(host, forHTTPHeaderField: "Host") + + let bodyHash = Self.sha256Hex(request.httpBody ?? Data()) + request.setValue(bodyHash, forHTTPHeaderField: "x-amz-content-sha256") + + let signedHeaders = Self.signedHeaders(request: request) + let canonicalRequest = Self.canonicalRequest( + request: request, + signedHeaders: signedHeaders, + bodyHash: bodyHash) + + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + Self.sha256Hex(Data(canonicalRequest.utf8)), + ].joined(separator: "\n") + + let signature = Self.calculateSignature( + secretKey: credentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service, + stringToSign: stringToSign) + + let authorization = "AWS4-HMAC-SHA256 " + + "Credential=\(credentials.accessKeyID)/\(credentialScope), " + + "SignedHeaders=\(signedHeaders.keys), " + + "Signature=\(signature)" + + request.setValue(authorization, forHTTPHeaderField: "Authorization") + } + + private struct SignedHeadersInfo { + let keys: String + let canonical: String + } + + private static func signedHeaders(request: URLRequest) -> SignedHeadersInfo { + var headers: [(String, String)] = [] + if let allHeaders = request.allHTTPHeaderFields { + for (key, value) in allHeaders { + headers.append((key.lowercased(), value.trimmingCharacters(in: .whitespaces))) + } + } + headers.sort { $0.0 < $1.0 } + + let keys = headers.map(\.0).joined(separator: ";") + let canonical = headers.map { "\($0.0):\($0.1)" }.joined(separator: "\n") + return SignedHeadersInfo(keys: keys, canonical: canonical) + } + + private static func canonicalRequest( + request: URLRequest, + signedHeaders: SignedHeadersInfo, + bodyHash: String) -> String + { + let method = request.httpMethod ?? "GET" + let url = request.url! + let path = url.path.isEmpty ? "/" : url.path + let query = Self.canonicalQueryString(url: url) + + return [ + method, + Self.uriEncodePath(path), + query, + signedHeaders.canonical + "\n", + signedHeaders.keys, + bodyHash, + ].joined(separator: "\n") + } + + private static func canonicalQueryString(url: URL) -> String { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + !queryItems.isEmpty + else { + return "" + } + + return queryItems + .map { item in + let key = Self.uriEncode(item.name) + let value = Self.uriEncode(item.value ?? "") + return "\(key)=\(value)" + } + .sorted() + .joined(separator: "&") + } + + private static func calculateSignature( + secretKey: String, + dateStamp: String, + region: String, + service: String, + stringToSign: String) -> String + { + let kDate = Self.hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) + let kRegion = Self.hmacSHA256(key: kDate, data: Data(region.utf8)) + let kService = Self.hmacSHA256(key: kRegion, data: Data(service.utf8)) + let kSigning = Self.hmacSHA256(key: kService, data: Data("aws4_request".utf8)) + let signature = Self.hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)) + return signature.map { String(format: "%02x", $0) }.joined() + } + + private static func hmacSHA256(key: Data, data: Data) -> Data { + let symmetricKey = SymmetricKey(data: key) + let mac = HMAC.authenticationCode(for: data, using: symmetricKey) + return Data(mac) + } + + private static func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func dateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } + + private static func dateStamp(date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } + + private static func uriEncode(_ string: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string + } + + private static func uriEncodePath(_ path: String) -> String { + path.split(separator: "/", omittingEmptySubsequences: false) + .map { self.uriEncode(String($0)) } + .joined(separator: "/") + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift new file mode 100644 index 000000000..ed65f00c1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -0,0 +1,79 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum BedrockProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .bedrock, + metadata: ProviderMetadata( + id: .bedrock, + displayName: "AWS Bedrock", + sessionLabel: "Budget", + weeklyLabel: "Cost", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show AWS Bedrock usage", + cliName: "bedrock", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://console.aws.amazon.com/bedrock", + statusPageURL: nil, + statusLinkURL: "https://health.aws.amazon.com/health/status"), + branding: ProviderBranding( + iconStyle: .bedrock, + iconResourceName: "ProviderIcon-bedrock", + color: ProviderColor(red: 1, green: 0.6, blue: 0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "No AWS Bedrock cost data available. Check your AWS credentials." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [BedrockAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "bedrock", + aliases: ["aws-bedrock"], + versionDetector: nil)) + } +} + +struct BedrockAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "bedrock.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + BedrockSettingsReader.hasCredentials(environment: context.env) + } + + 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 budget = BedrockSettingsReader.budget(environment: context.env) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: region, + budget: budget, + environment: context.env) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: any Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift new file mode 100644 index 000000000..c16b4b99c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum BedrockSettingsReader { + public static let accessKeyIDKey = "AWS_ACCESS_KEY_ID" + public static let secretAccessKeyKey = "AWS_SECRET_ACCESS_KEY" + public static let sessionTokenKey = "AWS_SESSION_TOKEN" + 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 defaultRegion = "us-east-1" + + public static func accessKeyID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.accessKeyIDKey]) + } + + public static func secretAccessKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.secretAccessKeyKey]) + } + + public static func sessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.sessionTokenKey]) + } + + public static func region(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + for key in self.regionKeys { + if let value = self.cleaned(environment[key]) { + return value + } + } + return self.defaultRegion + } + + public static func budget(environment: [String: String] = ProcessInfo.processInfo.environment) -> Double? { + guard let raw = self.cleaned(environment[self.budgetKey]), + let value = Double(raw), + value > 0 + else { + return nil + } + return value + } + + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.accessKeyID(environment: environment) != nil && + self.secretAccessKey(environment: environment) != nil + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift new file mode 100644 index 000000000..b9e812494 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -0,0 +1,438 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct BedrockUsageSnapshot: Codable, Sendable { + public let monthlySpend: Double + public let monthlyBudget: Double? + public let inputTokens: Int? + public let outputTokens: Int? + public let region: String + public let updatedAt: Date + + public init( + monthlySpend: Double, + monthlyBudget: Double?, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + region: String, + updatedAt: Date) + { + self.monthlySpend = monthlySpend + self.monthlyBudget = monthlyBudget + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.region = region + self.updatedAt = updatedAt + } + + public var budgetUsedPercent: Double? { + guard let budget = self.monthlyBudget, budget > 0 else { return nil } + return min(100, max(0, (self.monthlySpend / budget) * 100)) + } + + public var totalTokens: Int? { + guard let input = self.inputTokens, let output = self.outputTokens else { return nil } + return input + output + } +} + +extension BedrockUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let primary: RateWindow? = if let usedPercent = self.budgetUsedPercent { + RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: Self.endOfCurrentMonth(), + resetDescription: "Monthly budget") + } else { + nil + } + + let cost = ProviderCostSnapshot( + used: self.monthlySpend, + limit: self.monthlyBudget ?? 0, + currencyCode: "USD", + period: "Monthly", + resetsAt: Self.endOfCurrentMonth(), + updatedAt: self.updatedAt) + + var loginParts: [String] = [] + loginParts.append(String(format: "Spend: $%.2f", self.monthlySpend)) + if let budget = self.monthlyBudget { + loginParts.append(String(format: "Budget: $%.2f", budget)) + } + if let total = self.totalTokens { + loginParts.append("Tokens: \(Self.formattedTokenCount(total))") + } + + let identity = ProviderIdentitySnapshot( + providerID: .bedrock, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginParts.joined(separator: " - ")) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: cost, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func endOfCurrentMonth() -> Date? { + let calendar = Calendar.current + guard let range = calendar.range(of: .day, in: .month, for: Date()) else { return nil } + let components = calendar.dateComponents([.year, .month], from: Date()) + guard let startOfMonth = calendar.date(from: components) else { return nil } + return calendar.date(byAdding: .day, value: range.count, to: startOfMonth) + } + + static func formattedTokenCount(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1000 { + return String(format: "%.1fK", Double(count) / 1000) + } + return "\(count)" + } +} + +enum BedrockUsageFetcher { + private static let log = CodexBarLog.logger(LogCategories.bedrockUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + + private struct CostExplorerQuery { + let startDate: String + let endDate: String + let granularity: String + let nextPageToken: String? + } + + static func fetchUsage( + credentials: BedrockAWSSigner.Credentials, + region: String, + budget: Double?, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws + -> BedrockUsageSnapshot + { + let spend = try await Self.fetchMonthlyCost( + credentials: credentials, + environment: environment) + + return BedrockUsageSnapshot( + monthlySpend: spend, + monthlyBudget: budget, + inputTokens: nil, + outputTokens: nil, + region: region, + updatedAt: Date()) + } + + static func fetchDailyReport( + credentials: BedrockAWSSigner.Credentials, + since: Date, + until: Date, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws + -> CostUsageDailyReport + { + let formatter = Self.dateFormatter() + let startDate = formatter.string(from: since) + let inclusiveEnd = Self.utcCalendar().date(byAdding: .day, value: 1, to: until) ?? until + let endDate = formatter.string(from: inclusiveEnd) + + let pages = try await Self.callCostExplorerPages( + startDate: startDate, + endDate: endDate, + granularity: "DAILY", + credentials: credentials, + environment: environment) + + let entries = try Self.parseDailyResponses(pages) + return CostUsageDailyReport(data: entries, summary: nil) + } + + private static func fetchMonthlyCost( + credentials: BedrockAWSSigner.Credentials, + environment: [String: String]) async throws -> Double + { + let (startDate, endDate) = Self.currentMonthRange() + + let pages = try await Self.callCostExplorerPages( + startDate: startDate, + endDate: endDate, + granularity: "MONTHLY", + credentials: credentials, + environment: environment) + + return try Self.parseTotalCost(pages) + } + + private static func callCostExplorerPages( + startDate: String, + endDate: String, + granularity: String, + credentials: BedrockAWSSigner.Credentials, + environment: [String: String]) async throws -> [Data] + { + var pages: [Data] = [] + var nextPageToken: String? + var seenPageTokens: Set = [] + + repeat { + let page = try await Self.callCostExplorerPage( + query: CostExplorerQuery( + startDate: startDate, + endDate: endDate, + granularity: granularity, + nextPageToken: nextPageToken), + credentials: credentials, + environment: environment) + pages.append(page) + nextPageToken = try Self.nextPageToken(from: page) + if let nextPageToken, !seenPageTokens.insert(nextPageToken).inserted { + throw BedrockUsageError.parseFailed("Cost Explorer returned repeated NextPageToken") + } + } while nextPageToken != nil + + return pages + } + + private static func callCostExplorerPage( + query: CostExplorerQuery, + credentials: BedrockAWSSigner.Credentials, + environment: [String: String]) async throws -> Data + { + let ceRegion = "us-east-1" + let baseURL: URL = if let override = environment[BedrockSettingsReader.apiURLKey], + let url = URL(string: BedrockSettingsReader.cleaned(override) ?? "") + { + url + } else { + URL(string: "https://ce.\(ceRegion).amazonaws.com")! + } + + var requestBody: [String: Any] = [ + "TimePeriod": [ + "Start": query.startDate, + "End": query.endDate, + ], + "Granularity": query.granularity, + "Metrics": ["UnblendedCost"], + "GroupBy": [ + ["Type": "DIMENSION", "Key": "SERVICE"], + ], + ] + if let nextPageToken = query.nextPageToken { + requestBody["NextPageToken"] = nextPageToken + } + + let bodyData = try JSONSerialization.data(withJSONObject: requestBody) + + var request = URLRequest(url: baseURL) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue("application/x-amz-json-1.1", forHTTPHeaderField: "Content-Type") + request.setValue( + "AWSInsightsIndexService.GetCostAndUsage", + forHTTPHeaderField: "X-Amz-Target") + request.timeoutInterval = Self.requestTimeoutSeconds + + BedrockAWSSigner.sign( + request: &request, + credentials: credentials, + region: ceRegion, + service: "ce") + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + let summary = Self.sanitizedResponseBody(response.data) + Self.log.error("AWS Cost Explorer returned \(response.statusCode): \(summary)") + throw BedrockUsageError.apiError("HTTP \(response.statusCode)") + } + + return response.data + } + + private static func nextPageToken(from data: Data) throws -> String? { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw BedrockUsageError.parseFailed("Invalid Cost Explorer response") + } + return BedrockSettingsReader.cleaned(json["NextPageToken"] as? String) + } + + private static func parseTotalCost(_ pages: [Data]) throws -> Double { + var total = 0.0 + for page in pages { + total += try Self.parseTotalCost(page) + } + return total + } + + private static func parseTotalCost(_ data: Data) throws -> Double { + var total = 0.0 + for (_, cost, _) in try Self.parseGroupedResults(data) { + total += cost + } + return total + } + + private static func parseDailyResponses(_ pages: [Data]) throws -> [CostUsageDailyReport.Entry] { + let reports = try pages.map { page in + try CostUsageDailyReport(data: Self.parseDailyResponse(page), summary: nil) + } + return CostUsageDailyReport.merged(reports).data + } + + private static func parseDailyResponse(_ data: Data) throws -> [CostUsageDailyReport.Entry] { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["ResultsByTime"] as? [[String: Any]] + else { + throw BedrockUsageError.parseFailed("Missing ResultsByTime in Cost Explorer response") + } + + var entries: [CostUsageDailyReport.Entry] = [] + for result in results { + guard let timePeriod = result["TimePeriod"] as? [String: String], + let dateStr = timePeriod["Start"] + else { continue } + + var dayCost = 0.0 + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + + if let groups = result["Groups"] as? [[String: Any]] { + for group in groups { + guard let keys = group["Keys"] as? [String], + let serviceName = keys.first, + serviceName.localizedCaseInsensitiveContains("Bedrock") + else { continue } + + if let metrics = group["Metrics"] as? [String: Any], + let unblended = metrics["UnblendedCost"] as? [String: Any], + let amountStr = unblended["Amount"] as? String, + let amount = Double(amountStr), + amount > 0 + { + dayCost += amount + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: serviceName, + costUSD: amount)) + } + } + } + + guard dayCost > 0 else { continue } + + entries.append(CostUsageDailyReport.Entry( + date: dateStr, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: dayCost, + modelsUsed: breakdowns.map(\.modelName), + modelBreakdowns: breakdowns.isEmpty ? nil : breakdowns)) + } + + return entries + } + + private static func parseGroupedResults(_ data: Data) throws + -> [(service: String, cost: Double, date: String)] + { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["ResultsByTime"] as? [[String: Any]] + else { + throw BedrockUsageError.parseFailed("Missing ResultsByTime in Cost Explorer response") + } + + var items: [(service: String, cost: Double, date: String)] = [] + for result in results { + let dateStr = (result["TimePeriod"] as? [String: String])?["Start"] ?? "" + guard let groups = result["Groups"] as? [[String: Any]] else { continue } + for group in groups { + guard let keys = group["Keys"] as? [String], + let serviceName = keys.first, + serviceName.localizedCaseInsensitiveContains("Bedrock") + else { continue } + + if let metrics = group["Metrics"] as? [String: Any], + let unblended = metrics["UnblendedCost"] as? [String: Any], + let amountStr = unblended["Amount"] as? String, + let amount = Double(amountStr) + { + items.append((serviceName, amount, dateStr)) + } + } + } + return items + } + + private static func dateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } + + private static func utcCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + static func currentMonthRange(now: Date = Date()) -> (start: String, end: String) { + let calendar = Self.utcCalendar() + let components = calendar.dateComponents([.year, .month], from: now) + let startOfMonth = calendar.date(from: components)! + + let formatter = Self.dateFormatter() + let startOfToday = calendar.startOfDay(for: now) + let tomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday)! + return (formatter.string(from: startOfMonth), formatter.string(from: tomorrow)) + } + + private static func sanitizedResponseBody(_ data: Data) -> String { + guard !data.isEmpty, + let body = String(bytes: data, encoding: .utf8) + else { + return "empty body" + } + + let trimmed = body.replacingOccurrences( + of: #"\s+"#, + with: " ", + options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.count > 240 { + let index = trimmed.index(trimmed.startIndex, offsetBy: 240) + return "\(trimmed[.. String? { + for key in self.apiKeyEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return nil + } + + public static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum ClaudeAdminAPISettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "Claude API usage needs an Anthropic Admin API key." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageFetcher.swift new file mode 100644 index 000000000..37df52a03 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageFetcher.swift @@ -0,0 +1,432 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum ClaudeAdminAPIUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(endpoint: String, statusCode: Int) + case parseFailed(endpoint: String, message: String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Anthropic Admin API key." + case let .networkError(message): + "Claude API usage network error: \(message)" + case let .apiError(endpoint, statusCode): + "Claude API usage \(endpoint) error: HTTP \(statusCode)" + case let .parseFailed(endpoint, message): + "Failed to parse Claude API usage \(endpoint): \(message)" + } + } +} + +public enum ClaudeAdminAPIUsageFetcher { + public static let costReportURL = URL(string: "https://api.anthropic.com/v1/organizations/cost_report")! + public static let messagesUsageURL = + URL(string: "https://api.anthropic.com/v1/organizations/usage_report/messages")! + + private static let anthropicVersion = "2023-06-01" + private static let timeoutSeconds: TimeInterval = 20 + private static let maxDailyBuckets = 31 + + public static func fetchUsage( + apiKey: String, + costURL: URL = Self.costReportURL, + messagesURL: URL = Self.messagesUsageURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> ClaudeAdminAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ClaudeAdminAPIUsageError.missingCredentials + } + + let calendar = Self.utcCalendar + let range = Self.dailyRange(now: now, calendar: calendar) + let costs = try await Self.fetchCostReport( + apiKey: trimmed, + baseURL: costURL, + range: range, + transport: transport) + let messages = try await Self.fetchMessagesUsage( + apiKey: trimmed, + baseURL: messagesURL, + range: range, + transport: transport) + + return Self.makeSnapshot(costs: costs, messages: messages, now: now, calendar: calendar) + } + + static func _parseSnapshotForTesting( + costs: Data, + messages: Data, + now: Date, + calendar: Calendar = Self.utcCalendar) throws -> ClaudeAdminAPIUsageSnapshot + { + let costs = try Self.decodeCosts(costs) + let messages = try Self.decodeMessages(messages) + return Self.makeSnapshot(costs: costs, messages: messages, now: now, calendar: calendar) + } + + private static func fetchCostReport( + apiKey: String, + baseURL: URL, + range: DateRange, + transport: any ProviderHTTPTransport) async throws -> CostReportResponse + { + let url = Self.url( + baseURL: baseURL, + range: range, + queryItems: [ + URLQueryItem(name: "group_by[]", value: "description"), + ]) + let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "cost_report", transport: transport) + return try Self.decodeCosts(data) + } + + private static func fetchMessagesUsage( + apiKey: String, + baseURL: URL, + range: DateRange, + transport: any ProviderHTTPTransport) async throws -> MessagesUsageResponse + { + let url = Self.url( + baseURL: baseURL, + range: range, + queryItems: [ + URLQueryItem(name: "group_by[]", value: "model"), + ]) + let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "messages", transport: transport) + return try Self.decodeMessages(data) + } + + private static func fetchData( + url: URL, + apiKey: String, + endpoint: String, + transport: any ProviderHTTPTransport) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue(Self.anthropicVersion, forHTTPHeaderField: "anthropic-version") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw ClaudeAdminAPIUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + throw ClaudeAdminAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode) + } + return response.data + } + + private static func decodeCosts(_ data: Data) throws -> CostReportResponse { + do { + return try JSONDecoder().decode(CostReportResponse.self, from: data) + } catch { + throw ClaudeAdminAPIUsageError.parseFailed(endpoint: "cost_report", message: error.localizedDescription) + } + } + + private static func decodeMessages(_ data: Data) throws -> MessagesUsageResponse { + do { + return try JSONDecoder().decode(MessagesUsageResponse.self, from: data) + } catch { + throw ClaudeAdminAPIUsageError.parseFailed(endpoint: "messages", message: error.localizedDescription) + } + } + + private static func makeSnapshot( + costs: CostReportResponse, + messages: MessagesUsageResponse, + now: Date, + calendar: Calendar) -> ClaudeAdminAPIUsageSnapshot + { + var accumulators: [String: DailyAccumulator] = [:] + + for bucket in costs.data { + var accumulator = accumulators[bucket.startingAt] ?? DailyAccumulator( + startingAt: bucket.startingAt, + endingAt: bucket.endingAt) + for result in bucket.results { + // Anthropic Usage & Cost API docs define `amount` as a decimal string in lowest USD units. + let value = Self.usdFromAnthropicLowestUnitAmount(result.amount) + accumulator.costUSD += value + let item = Self.displayName(result.description ?? result.costType, fallback: "Claude API") + accumulator.costItems[item, default: 0] += value + } + accumulators[bucket.startingAt] = accumulator + } + + for bucket in messages.data { + var accumulator = accumulators[bucket.startingAt] ?? DailyAccumulator( + startingAt: bucket.startingAt, + endingAt: bucket.endingAt) + for result in bucket.results { + let input = result.uncachedInputTokens ?? 0 + let cacheCreation = result.cacheCreation?.totalInputTokens ?? 0 + let cacheRead = result.cacheReadInputTokens ?? 0 + let output = result.outputTokens ?? 0 + let total = input + cacheCreation + cacheRead + output + accumulator.inputTokens += input + accumulator.cacheCreationInputTokens += cacheCreation + accumulator.cacheReadInputTokens += cacheRead + accumulator.outputTokens += output + accumulator.totalTokens += total + let modelName = Self.displayName(result.model, fallback: "Claude API") + accumulator.models[modelName, default: ModelAccumulator()].add( + inputTokens: input, + cacheCreationInputTokens: cacheCreation, + cacheReadInputTokens: cacheRead, + outputTokens: output, + totalTokens: total) + } + accumulators[bucket.startingAt] = accumulator + } + + let daily = accumulators.values + .compactMap { $0.makeBucket(calendar: calendar) } + .filter { $0.startTime <= now } + .sorted { $0.startTime < $1.startTime } + return ClaudeAdminAPIUsageSnapshot(daily: daily, updatedAt: now) + } + + private static func displayName(_ raw: String?, fallback: String) -> String { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return fallback + } + return trimmed + } + + private static func usdFromAnthropicLowestUnitAmount(_ raw: String) -> Double { + (Double(raw) ?? 0) / 100 + } + + private static func url(baseURL: URL, range: DateRange, queryItems extraItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "starting_at", value: Self.rfc3339String(from: range.start)), + URLQueryItem(name: "ending_at", value: Self.rfc3339String(from: range.end)), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: String(Self.maxDailyBuckets)), + ] + extraItems + return components.url! + } + + private static func dailyRange(now: Date, calendar: Calendar) -> DateRange { + let today = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(Self.maxDailyBuckets - 1), to: today) ?? today + let end = calendar.date(byAdding: .day, value: 1, to: today) ?? now + return DateRange(start: start, end: end) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func rfc3339Formatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + + private static func rfc3339String(from date: Date) -> String { + self.rfc3339Formatter().string(from: date) + } + + fileprivate static func dayKey(from date: Date, calendar: Calendar) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + fileprivate static func parseDate(_ raw: String) -> Date? { + self.rfc3339Formatter().date(from: raw) + } +} + +private struct DateRange { + let start: Date + let end: Date +} + +private struct DailyAccumulator { + let startingAt: String + let endingAt: String + var costUSD: Double = 0 + var inputTokens: Int = 0 + var cacheCreationInputTokens: Int = 0 + var cacheReadInputTokens: Int = 0 + var outputTokens: Int = 0 + var totalTokens: Int = 0 + var costItems: [String: Double] = [:] + var models: [String: ModelAccumulator] = [:] + + func makeBucket(calendar: Calendar) -> ClaudeAdminAPIUsageSnapshot.DailyBucket? { + guard let start = ClaudeAdminAPIUsageFetcher.parseDate(self.startingAt), + let end = ClaudeAdminAPIUsageFetcher.parseDate(self.endingAt) + else { return nil } + return ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: ClaudeAdminAPIUsageFetcher.dayKey(from: start, calendar: calendar), + startTime: start, + endTime: end, + costUSD: self.costUSD, + inputTokens: self.inputTokens, + cacheCreationInputTokens: self.cacheCreationInputTokens, + cacheReadInputTokens: self.cacheReadInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens, + costItems: self.costItems + .map { ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + }, + models: self.models + .map { name, total in total.makeModel(name: name) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } +} + +private struct ModelAccumulator { + var inputTokens = 0 + var cacheCreationInputTokens = 0 + var cacheReadInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add( + inputTokens: Int, + cacheCreationInputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.inputTokens += inputTokens + self.cacheCreationInputTokens += cacheCreationInputTokens + self.cacheReadInputTokens += cacheReadInputTokens + self.outputTokens += outputTokens + self.totalTokens += totalTokens + } + + func makeModel(name: String) -> ClaudeAdminAPIUsageSnapshot.ModelBreakdown { + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: name, + inputTokens: self.inputTokens, + cacheCreationInputTokens: self.cacheCreationInputTokens, + cacheReadInputTokens: self.cacheReadInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } +} + +private struct CostReportResponse: Decodable { + let data: [CostBucket] + let hasMore: Bool? + let nextPage: String? + + private enum CodingKeys: String, CodingKey { + case data + case hasMore = "has_more" + case nextPage = "next_page" + } +} + +private struct CostBucket: Decodable { + let startingAt: String + let endingAt: String + let results: [CostResult] + + private enum CodingKeys: String, CodingKey { + case startingAt = "starting_at" + case endingAt = "ending_at" + case results + } +} + +private struct CostResult: Decodable { + let currency: String? + let amount: String + let description: String? + let costType: String? + + private enum CodingKeys: String, CodingKey { + case currency + case amount + case description + case costType = "cost_type" + } +} + +private struct MessagesUsageResponse: Decodable { + let data: [MessagesBucket] + let hasMore: Bool? + let nextPage: String? + + private enum CodingKeys: String, CodingKey { + case data + case hasMore = "has_more" + case nextPage = "next_page" + } +} + +private struct MessagesBucket: Decodable { + let startingAt: String + let endingAt: String + let results: [MessagesResult] + + private enum CodingKeys: String, CodingKey { + case startingAt = "starting_at" + case endingAt = "ending_at" + case results + } +} + +private struct MessagesResult: Decodable { + let uncachedInputTokens: Int? + let cacheCreation: CacheCreation? + let cacheReadInputTokens: Int? + let outputTokens: Int? + let model: String? + + private enum CodingKeys: String, CodingKey { + case uncachedInputTokens = "uncached_input_tokens" + case cacheCreation = "cache_creation" + case cacheReadInputTokens = "cache_read_input_tokens" + case outputTokens = "output_tokens" + case model + } +} + +private struct CacheCreation: Decodable { + let ephemeral1HInputTokens: Int? + let ephemeral5MInputTokens: Int? + + var totalInputTokens: Int { + (self.ephemeral1HInputTokens ?? 0) + (self.ephemeral5MInputTokens ?? 0) + } + + private enum CodingKeys: String, CodingKey { + case ephemeral1HInputTokens = "ephemeral_1h_input_tokens" + case ephemeral5MInputTokens = "ephemeral_5m_input_tokens" + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift new file mode 100644 index 000000000..f9b82ab0c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift @@ -0,0 +1,222 @@ +import Foundation + +public struct ClaudeAdminAPIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let day: String + public let startTime: Date + public let endTime: Date + public let costUSD: Double + public let inputTokens: Int + public let cacheCreationInputTokens: Int + public let cacheReadInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + public let costItems: [CostBreakdown] + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public init( + day: String, + startTime: Date, + endTime: Date, + costUSD: Double, + inputTokens: Int, + cacheCreationInputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + totalTokens: Int, + costItems: [CostBreakdown], + models: [ModelBreakdown]) + { + self.day = day + self.startTime = startTime + self.endTime = endTime + self.costUSD = costUSD + self.inputTokens = inputTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.costItems = costItems + self.models = models + } + } + + public struct CostBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let costUSD: Double + + public var id: String { + self.name + } + + public init(name: String, costUSD: Double) { + self.name = name + self.costUSD = costUSD + } + } + + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let inputTokens: Int + public let cacheCreationInputTokens: Int + public let cacheReadInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public var id: String { + self.name + } + + public init( + name: String, + inputTokens: Int, + cacheCreationInputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.name = name + self.inputTokens = inputTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public struct Summary: Equatable, Sendable { + public let costUSD: Double + public let inputTokens: Int + public let cacheCreationInputTokens: Int + public let cacheReadInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public init( + costUSD: Double, + inputTokens: Int, + cacheCreationInputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.costUSD = costUSD + self.inputTokens = inputTokens + self.cacheCreationInputTokens = cacheCreationInputTokens + self.cacheReadInputTokens = cacheReadInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public let daily: [DailyBucket] + public let updatedAt: Date + + public init(daily: [DailyBucket], updatedAt: Date) { + self.daily = daily.sorted { $0.startTime < $1.startTime } + self.updatedAt = updatedAt + } + + public var last30Days: Summary { + self.summary(days: 30) + } + + public var last7Days: Summary { + self.summary(days: 7) + } + + public var latestDay: Summary { + self.summary(days: 1) + } + + public func summary(days: Int) -> Summary { + let selected = self.daily.suffix(max(1, days)) + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cacheCreationInputTokens: selected.reduce(0) { $0 + $1.cacheCreationInputTokens }, + cacheReadInputTokens: selected.reduce(0) { $0 + $1.cacheReadInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + + public var topModels: [ModelBreakdown] { + var totals: [String: ModelAccumulator] = [:] + for day in self.daily { + for model in day.models { + totals[model.name, default: ModelAccumulator()].add(model) + } + } + return totals + .map { name, total in total.makeModel(name: name) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + } + } + + public var topCostItems: [CostBreakdown] { + var totals: [String: Double] = [:] + for day in self.daily { + for item in day.costItems { + totals[item.name, default: 0] += item.costUSD + } + } + return totals + .map { CostBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + } + } + + public func toUsageSnapshot() -> UsageSnapshot { + let total = self.last30Days + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: total.costUSD, + limit: 0, + currencyCode: "USD", + period: "Last 30 days", + updatedAt: self.updatedAt), + claudeAdminAPIUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Admin API")) + } + + private struct ModelAccumulator { + var inputTokens = 0 + var cacheCreationInputTokens = 0 + var cacheReadInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add(_ model: ModelBreakdown) { + self.inputTokens += model.inputTokens + self.cacheCreationInputTokens += model.cacheCreationInputTokens + self.cacheReadInputTokens += model.cacheReadInputTokens + self.outputTokens += model.outputTokens + self.totalTokens += model.totalTokens + } + + func makeModel(name: String) -> ModelBreakdown { + ModelBreakdown( + name: name, + inputTokens: self.inputTokens, + cacheCreationInputTokens: self.cacheCreationInputTokens, + cacheReadInputTokens: self.cacheReadInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index b96908e11..d2ccd1c6d 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -8,6 +8,25 @@ import Foundation actor ClaudeCLISession { static let shared = ClaudeCLISession() private static let log = CodexBarLog.logger(LogCategories.claudeCLI) + #if DEBUG + @TaskLocal private static var sessionOverrideForTesting: ClaudeCLISession? + + static var current: ClaudeCLISession { + self.sessionOverrideForTesting ?? self.shared + } + + static func withIsolatedSessionForTesting(operation: () async throws -> T) async rethrows -> T { + let session = ClaudeCLISession() + defer { Task { await session.reset() } } + return try await self.$sessionOverrideForTesting.withValue(session) { + try await operation() + } + } + #else + static var current: ClaudeCLISession { + self.shared + } + #endif enum SessionError: LocalizedError { case launchFailed(String) @@ -98,6 +117,7 @@ actor ClaudeCLISession { timeout: TimeInterval, idleTimeout: TimeInterval? = 3.0, stopOnSubstrings: [String] = [], + stopWhenNormalized: (@Sendable (String) -> Bool)? = nil, settleAfterStop: TimeInterval = 0.25, sendEnterEvery: TimeInterval? = nil) async throws -> String { @@ -136,6 +156,7 @@ actor ClaudeCLISession { var buffer = Data() var scanTailText = "" + var normalizedScan = "" var utf8Carry = Data() let deadline = Date().addingTimeInterval(timeout) var lastOutputAt = Date() @@ -152,27 +173,26 @@ actor ClaudeCLISession { lastOutputAt = Date() Self.appendScanText(newData: newData, scanTailText: &scanTailText, utf8Carry: &utf8Carry) if scanTailText.count > 8192 { scanTailText = String(scanTailText.suffix(8192)) } - } - - let scanData = scanBuffer.append(newData) - if !scanData.isEmpty, - scanData.range(of: cursorQuery) != nil - { - try? self.send("\u{1b}[1;1R") - } + normalizedScan = Self.normalizedNeedle(TextParsing.stripANSICodes(scanTailText)) - let normalizedScan = Self.normalizedNeedle(TextParsing.stripANSICodes(scanTailText)) + let scanData = scanBuffer.append(newData) + if scanData.range(of: cursorQuery) != nil { + try? self.send("\u{1b}[1;1R") + } - for item in sendNeedles where !triggeredSends.contains(item.needle) { - if normalizedScan.contains(item.needle) { - try? self.send(item.keys) - triggeredSends.insert(item.needle) + for item in sendNeedles where !triggeredSends.contains(item.needle) { + if normalizedScan.contains(item.needle) { + try? self.send(item.keys) + triggeredSends.insert(item.needle) + } } - } - if stopNeedles.contains(where: normalizedScan.contains) { - stoppedEarly = true - break + if stopNeedles + .contains(where: normalizedScan.contains) || (stopWhenNormalized?(normalizedScan) == true) + { + stoppedEarly = true + break + } } if self.shouldStopForIdleTimeout( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift index b887869d9..2472de27f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCredentialRouting.swift @@ -4,6 +4,7 @@ public enum ClaudeCredentialRouting: Sendable, Equatable { case none case oauth(accessToken: String) case webCookie(header: String) + case adminAPIKey(String) public static func resolve(tokenAccountToken: String?, manualCookieHeader: String?) -> Self { if let tokenAccountToken, @@ -33,7 +34,15 @@ public enum ClaudeCredentialRouting: Sendable, Equatable { return false } + public var adminAPIKey: String? { + guard case let .adminAPIKey(key) = self else { return nil } + return key + } + private static func resolvePrimaryCredential(_ raw: String) -> Self? { + if let adminKey = self.normalizedAdminAPIKey(raw) { + return .adminAPIKey(adminKey) + } if let accessToken = self.normalizedOAuthToken(raw) { return .oauth(accessToken: accessToken) } @@ -59,6 +68,21 @@ public enum ClaudeCredentialRouting: Sendable, Equatable { return lower.hasPrefix("sk-ant-oat") ? trimmed : nil } + private static func normalizedAdminAPIKey(_ raw: String?) -> String? { + guard let trimmed = self.cleaned(raw) else { return nil } + let lower = trimmed.lowercased() + if lower.contains("cookie:") || trimmed.contains("=") { + return nil + } + if lower.hasPrefix("bearer ") { + let bearerTrimmed = trimmed.dropFirst("bearer ".count) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !bearerTrimmed.isEmpty else { return nil } + return bearerTrimmed.lowercased().hasPrefix("sk-ant-admin") ? bearerTrimmed : nil + } + return lower.hasPrefix("sk-ant-admin") ? trimmed : nil + } + private static func normalizedWebCookie(_ raw: String?) -> String? { guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } if normalized.contains("=") { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift index a62a13819..a94084607 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentialModels.swift @@ -10,19 +10,22 @@ public struct ClaudeOAuthCredentials: Sendable { public let expiresAt: Date? public let scopes: [String] public let rateLimitTier: String? + public let subscriptionType: String? public init( accessToken: String, refreshToken: String?, expiresAt: Date?, scopes: [String], - rateLimitTier: String?) + rateLimitTier: String?, + subscriptionType: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.expiresAt = expiresAt self.scopes = scopes self.rateLimitTier = rateLimitTier + self.subscriptionType = subscriptionType } public var isExpired: Bool { @@ -55,7 +58,8 @@ public struct ClaudeOAuthCredentials: Sendable { refreshToken: oauth.refreshToken, expiresAt: expiresAt, scopes: oauth.scopes ?? [], - rateLimitTier: oauth.rateLimitTier) + rateLimitTier: oauth.rateLimitTier, + subscriptionType: oauth.subscriptionType) } private struct Root: Decodable { @@ -68,6 +72,7 @@ public struct ClaudeOAuthCredentials: Sendable { let expiresAt: Double? let scopes: [String]? let rateLimitTier: String? + let subscriptionType: String? enum CodingKeys: String, CodingKey { case accessToken @@ -75,6 +80,7 @@ public struct ClaudeOAuthCredentials: Sendable { case expiresAt case scopes case rateLimitTier + case subscriptionType } } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 6388be800..798fb98d5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -951,13 +951,15 @@ public enum ClaudeOAuthCredentialsStore { func refreshAccessToken( refreshToken: String, existingScopes: [String], - existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials + existingRateLimitTier: String?, + existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials { try await self.context.run { let newCredentials = try await self.refreshAccessTokenCore( refreshToken: refreshToken, existingScopes: existingScopes, - existingRateLimitTier: existingRateLimitTier) + existingRateLimitTier: existingRateLimitTier, + existingSubscriptionType: existingSubscriptionType) ClaudeOAuthCredentialsStore.saveRefreshedCredentialsToCache(newCredentials) ClaudeOAuthCredentialsStore.writeMemoryCache( @@ -975,7 +977,8 @@ public enum ClaudeOAuthCredentialsStore { private func refreshAccessTokenCore( refreshToken: String, existingScopes: [String], - existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials + existingRateLimitTier: String?, + existingSubscriptionType: String?) async throws -> ClaudeOAuthCredentials { guard ClaudeOAuthRefreshFailureGate.shouldAttempt() else { let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus() @@ -1008,22 +1011,18 @@ public enum ClaudeOAuthCredentialsStore { ] request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let http = response as? HTTPURLResponse else { - throw ClaudeOAuthCredentialsError.refreshFailed("Invalid response") - } - - guard http.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { if let disposition = ClaudeOAuthCredentialsStore.refreshFailureDisposition( - statusCode: http.statusCode, + statusCode: response.statusCode, data: data) { let oauthError = ClaudeOAuthCredentialsStore.extractOAuthErrorCode(from: data) ClaudeOAuthCredentialsStore.log.info( "Claude OAuth refresh rejected", metadata: [ - "httpStatus": "\(http.statusCode)", + "httpStatus": "\(response.statusCode)", "oauthError": oauthError ?? "nil", "disposition": disposition.rawValue, ]) @@ -1032,15 +1031,17 @@ public enum ClaudeOAuthCredentialsStore { case .terminalInvalidGrant: ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure() Repository(context: self.context).invalidateCache() + let message = "HTTP \(response.statusCode) invalid_grant. " + + ClaudeOAuthCredentialsStore.reauthenticateHint throw ClaudeOAuthCredentialsError.refreshFailed( - "HTTP \(http.statusCode) invalid_grant. \(ClaudeOAuthCredentialsStore.reauthenticateHint)") + message) case .transientBackoff: ClaudeOAuthRefreshFailureGate.recordTransientFailure() let suffix = oauthError.map { " (\($0))" } ?? "" - throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(http.statusCode)\(suffix)") + throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)\(suffix)") } } - throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(http.statusCode)") + throw ClaudeOAuthCredentialsError.refreshFailed("HTTP \(response.statusCode)") } let tokenResponse = try JSONDecoder().decode(TokenRefreshResponse.self, from: data) @@ -1051,7 +1052,8 @@ public enum ClaudeOAuthCredentialsStore { refreshToken: tokenResponse.refreshToken ?? refreshToken, expiresAt: expiresAt, scopes: existingScopes, - rateLimitTier: existingRateLimitTier) + rateLimitTier: existingRateLimitTier, + subscriptionType: existingSubscriptionType) } } @@ -1144,7 +1146,8 @@ public enum ClaudeOAuthCredentialsStore { let refreshed = try await refresher.refreshAccessToken( refreshToken: refreshToken, existingScopes: credentials.scopes, - existingRateLimitTier: credentials.rateLimitTier) + existingRateLimitTier: credentials.rateLimitTier, + existingSubscriptionType: credentials.subscriptionType) self.log.info("Token refresh successful, expires in \(refreshed.expiresIn ?? 0) seconds") return refreshed } catch { @@ -1167,6 +1170,9 @@ public enum ClaudeOAuthCredentialsStore { if let rateLimitTier = credentials.rateLimitTier { oauth["rateLimitTier"] = rateLimitTier } + if let subscriptionType = credentials.subscriptionType { + oauth["subscriptionType"] = subscriptionType + } let oauthData: [String: Any] = ["claudeAiOauth": oauth] @@ -1206,6 +1212,62 @@ public enum ClaudeOAuthCredentialsStore { } } + public static func credentialsFileFingerprintToken() -> String? { + guard let fingerprint = self.currentFileFingerprint() else { return nil } + let modifiedAt = fingerprint.modifiedAtMs.map(String.init) ?? "nil" + return "\(modifiedAt):\(fingerprint.size)" + } + + public static func authFingerprintToken() -> String { + let file = self.credentialsFileFingerprintToken() ?? "nil" + let keychain = self.claudeKeychainFingerprintToken() ?? "nil" + return "file=\(file)|keychain=\(keychain)" + } + + public static func consumeClaudeKeychainFingerprintChangeWithoutPrompt() -> Bool { + let current: ClaudeKeychainFingerprint? + switch self.probeClaudeKeychainFingerprintWithoutPrompt() { + case .unavailable: + return false + case let .value(fingerprint): + current = fingerprint + } + let stored = self.loadClaudeKeychainFingerprint() + guard current != stored else { return false } + self.saveClaudeKeychainFingerprint(current) + return true + } + + public static func claudeKeychainFingerprintChangedWithoutConsuming() -> Bool { + let current: ClaudeKeychainFingerprint? + switch self.probeClaudeKeychainFingerprintWithoutPrompt() { + case .unavailable: + return false + case let .value(fingerprint): + current = fingerprint + } + return current != self.loadClaudeKeychainFingerprint() + } + + public static func claudeKeychainFingerprintToken() -> String? { + let fingerprint: ClaudeKeychainFingerprint? = switch self.probeClaudeKeychainFingerprintWithoutPrompt() { + case .unavailable: + self.loadClaudeKeychainFingerprint() + case let .value(probed): + probed + } + guard let fingerprint else { return nil } + let modifiedAt = fingerprint.modifiedAt.map(String.init) ?? "nil" + let createdAt = fingerprint.createdAt.map(String.init) ?? "nil" + let persistentRefHash = fingerprint.persistentRefHash ?? "nil" + return "\(modifiedAt):\(createdAt):\(persistentRefHash)" + } + + private enum ClaudeKeychainProbe { + case unavailable + case value(Value) + } + @discardableResult public static func invalidateCacheIfCredentialsFileChanged() -> Bool { Repository(context: self.currentCollaboratorContext()).invalidateCacheIfCredentialsFileChanged() @@ -1282,27 +1344,58 @@ public enum ClaudeOAuthCredentialsStore { } private static func currentClaudeKeychainFingerprintWithoutPrompt() -> ClaudeKeychainFingerprint? { + switch self.probeClaudeKeychainFingerprintWithoutPrompt() { + case .unavailable: + nil + case let .value(fingerprint): + fingerprint + } + } + + private static func probeClaudeKeychainFingerprintWithoutPrompt() + -> ClaudeKeychainProbe { let mode = ClaudeOAuthKeychainPromptPreference.current() - guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return nil } #if DEBUG - if let store = taskClaudeKeychainOverrideStore { return store.fingerprint } + if let store = taskClaudeKeychainOverrideStore { return .value(store.fingerprint) } if let override = taskClaudeKeychainFingerprintOverride ?? self - .claudeKeychainFingerprintOverride { return override } + .claudeKeychainFingerprintOverride { return .value(override) } #endif + guard self.shouldAllowClaudeCodeKeychainAccess(mode: mode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() + { + return .unavailable + } #if os(macOS) - let newest: ClaudeKeychainCandidate? = self.claudeKeychainCandidatesWithoutPrompt().first - ?? self.claudeKeychainLegacyCandidateWithoutPrompt() - guard let newest else { return nil } + let candidatesProbe = self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: mode) + let newest: ClaudeKeychainCandidate? + switch candidatesProbe { + case .unavailable: + return .unavailable + case let .value(candidates): + if let first = candidates.first { + newest = first + } else { + switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: mode) { + case .unavailable: + return .unavailable + case let .value(candidate): + newest = candidate + } + } + } + guard let newest else { return .value(nil) } let modifiedAt = newest.modifiedAt.map { Int($0.timeIntervalSince1970) } let createdAt = newest.createdAt.map { Int($0.timeIntervalSince1970) } let persistentRefHash = Self.sha256Prefix(newest.persistentRef) - return ClaudeKeychainFingerprint( + return .value(ClaudeKeychainFingerprint( modifiedAt: modifiedAt, createdAt: createdAt, - persistentRefHash: persistentRefHash) + persistentRefHash: persistentRefHash)) #else - return nil + return .unavailable #endif } @@ -1466,14 +1559,14 @@ public enum ClaudeOAuthCredentialsStore { let createdAt: Date? } - private static func claudeKeychainCandidatesWithoutPrompt( + private static func claudeKeychainCandidatesProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> [ClaudeKeychainCandidate] + .current()) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return [] } + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } if self.isPromptPolicyApplicable, ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return [] } + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, @@ -1485,12 +1578,13 @@ public enum ClaudeOAuthCredentialsStore { let (status, result, durationMs) = ClaudeOAuthKeychainQueryTiming.copyMatching(query) if ClaudeOAuthKeychainQueryTiming - .backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log) { return [] } + .backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log) { return .unavailable } if status == errSecUserCanceled || status == errSecAuthFailed || status == errSecNoAccessForItem { ClaudeOAuthKeychainAccessGate.recordDenied() } - guard status == errSecSuccess else { return [] } - guard let rows = result as? [[String: Any]], !rows.isEmpty else { return [] } + if status == errSecItemNotFound { return .value([]) } + guard status == errSecSuccess else { return .unavailable } + guard let rows = result as? [[String: Any]], !rows.isEmpty else { return .value([]) } let candidates: [ClaudeKeychainCandidate] = rows.compactMap { row in guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return nil } @@ -1501,21 +1595,34 @@ public enum ClaudeOAuthCredentialsStore { createdAt: row[kSecAttrCreationDate as String] as? Date) } - return candidates.sorted { lhs, rhs in + let sorted = candidates.sorted { lhs, rhs in let lhsDate = lhs.modifiedAt ?? lhs.createdAt ?? Date.distantPast let rhsDate = rhs.modifiedAt ?? rhs.createdAt ?? Date.distantPast return lhsDate > rhsDate } + return .value(sorted) } - private static func claudeKeychainLegacyCandidateWithoutPrompt( + private static func claudeKeychainCandidatesWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainCandidate? + .current()) -> [ClaudeKeychainCandidate] { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return nil } + switch self.claudeKeychainCandidatesProbeWithoutPrompt(promptMode: promptMode) { + case .unavailable: + [] + case let .value(candidates): + candidates + } + } + + private static func claudeKeychainLegacyCandidateProbeWithoutPrompt( + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference + .current()) -> ClaudeKeychainProbe + { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } if self.isPromptPolicyApplicable, ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return nil } + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, @@ -1527,18 +1634,31 @@ public enum ClaudeOAuthCredentialsStore { let (status, result, durationMs) = ClaudeOAuthKeychainQueryTiming.copyMatching(query) if ClaudeOAuthKeychainQueryTiming - .backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log) { return nil } + .backoffIfSlowNoUIQuery(durationMs, self.claudeKeychainService, self.log) { return .unavailable } if status == errSecUserCanceled || status == errSecAuthFailed || status == errSecNoAccessForItem { ClaudeOAuthKeychainAccessGate.recordDenied() } - guard status == errSecSuccess else { return nil } - guard let row = result as? [String: Any] else { return nil } - guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return nil } - return ClaudeKeychainCandidate( + if status == errSecItemNotFound { return .value(nil) } + guard status == errSecSuccess else { return .unavailable } + guard let row = result as? [String: Any] else { return .value(nil) } + guard let persistentRef = row[kSecValuePersistentRef as String] as? Data else { return .value(nil) } + return .value(ClaudeKeychainCandidate( persistentRef: persistentRef, account: row[kSecAttrAccount as String] as? String, modifiedAt: row[kSecAttrModificationDate as String] as? Date, - createdAt: row[kSecAttrCreationDate as String] as? Date) + createdAt: row[kSecAttrCreationDate as String] as? Date)) + } + + private static func claudeKeychainLegacyCandidateWithoutPrompt( + promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference + .current()) -> ClaudeKeychainCandidate? + { + switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(promptMode: promptMode) { + case .unavailable: + nil + case let .value(candidate): + candidate + } } private static func loadClaudeKeychainData( @@ -1918,12 +2038,14 @@ extension ClaudeOAuthCredentialsStore { public static func refreshAccessToken( refreshToken: String, existingScopes: [String], - existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials + existingRateLimitTier: String?, + existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials { try await Refresher(context: self.currentCollaboratorContext()).refreshAccessToken( refreshToken: refreshToken, existingScopes: existingScopes, - existingRateLimitTier: existingRateLimitTier) + existingRateLimitTier: existingRateLimitTier, + existingSubscriptionType: existingSubscriptionType) } private enum RefreshFailureDisposition: String { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 33e8677e7..10b481bf8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -52,21 +52,19 @@ enum ClaudeOAuthUsageFetcher { request.setValue(Self.claudeCodeUserAgent(), forHTTPHeaderField: "User-Agent") do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw ClaudeOAuthFetchError.invalidResponse - } - switch http.statusCode { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + switch response.statusCode { case 200: return try Self.decodeUsageResponse(data) case 401: throw ClaudeOAuthFetchError.unauthorized case 403: let body = String(data: data, encoding: .utf8) - throw ClaudeOAuthFetchError.serverError(http.statusCode, body) + throw ClaudeOAuthFetchError.serverError(response.statusCode, body) default: let body = String(data: data, encoding: .utf8) - throw ClaudeOAuthFetchError.serverError(http.statusCode, body) + throw ClaudeOAuthFetchError.serverError(response.statusCode, body) } } catch let error as ClaudeOAuthFetchError { throw error diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift new file mode 100644 index 000000000..166c331ad --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum ClaudePeakHours: Sendable { + private static let peakTimeZone = TimeZone(identifier: "America/New_York")! + private static let peakStartHour = 8 + private static let peakEndHour = 14 + + public struct Status: Sendable, Equatable { + public let isPeak: Bool + public let label: String + } + + public static func status(at date: Date) -> Status { + let calendar = self.calendar() + let date = calendar.dateInterval(of: .minute, for: date)?.start ?? date + let components = calendar.dateComponents([.hour, .minute, .weekday], from: date) + + guard let hour = components.hour, + let minute = components.minute, + let weekday = components.weekday + else { + return Status(isPeak: false, label: "Off-peak") + } + + let isWeekday = weekday >= 2 && weekday <= 6 + let nowMinutes = hour * 60 + minute + let peakStartMinutes = self.peakStartHour * 60 + let peakEndMinutes = self.peakEndHour * 60 + let isInPeakWindow = nowMinutes >= peakStartMinutes && nowMinutes < peakEndMinutes + + if isWeekday, isInPeakWindow { + let remaining = peakEndMinutes - nowMinutes + return Status( + isPeak: true, + label: "Peak · ends in \(self.formatDuration(minutes: remaining))") + } + + let nextPeak = self.nextPeakStart(after: date, calendar: calendar) + let seconds = nextPeak.timeIntervalSince(date) + let minutes = max(Int(seconds / 60), 0) + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: minutes))") + } + + private static func nextPeakStart(after date: Date, calendar: Calendar) -> Date { + guard let todayPeak = calendar.date( + bySettingHour: self.peakStartHour, + minute: 0, + second: 0, + of: date) else { return date } + + let anchor = todayPeak > date ? todayPeak : calendar.date(byAdding: .day, value: 1, to: todayPeak) ?? date + let weekday = calendar.component(.weekday, from: anchor) + + let skip = switch weekday { + case 1: 1 + case 7: 2 + default: 0 + } + + if skip == 0 { return anchor } + return calendar.date(byAdding: .day, value: skip, to: anchor) ?? anchor + } + + private static func formatDuration(minutes: Int) -> String { + let h = minutes / 60 + let m = minutes % 60 + if h == 0 { + return "\(m)m" + } + if m == 0 { + return "\(h)h" + } + return "\(h)h \(m)m" + } + + private static func calendar() -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = self.peakTimeZone + return cal + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift index e85de6699..73c567106 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift @@ -50,6 +50,11 @@ public enum ClaudePlan: String, CaseIterable, Sendable { self.fromRateLimitTier(rateLimitTier) } + public static func fromOAuthCredentials(subscriptionType: String?, rateLimitTier: String?) -> Self? { + self.fromCompatibilityLoginMethod(subscriptionType) + ?? self.fromOAuthRateLimitTier(rateLimitTier) + } + public static func fromWebAccount(rateLimitTier: String?, billingType: String?) -> Self? { if let plan = self.fromRateLimitTier(rateLimitTier) { return plan @@ -90,6 +95,12 @@ public enum ClaudePlan: String, CaseIterable, Sendable { self.fromOAuthRateLimitTier(rateLimitTier)?.brandedLoginMethod } + public static func oauthLoginMethod(subscriptionType: String?, rateLimitTier: String?) -> String? { + self.fromOAuthCredentials( + subscriptionType: subscriptionType, + rateLimitTier: rateLimitTier)?.brandedLoginMethod + } + public static func webLoginMethod(rateLimitTier: String?, billingType: String?) -> String? { self.fromWebAccount(rateLimitTier: rateLimitTier, billingType: billingType)?.brandedLoginMethod } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 20db61312..e316b7449 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -24,6 +24,7 @@ public enum ClaudeProviderDescriptor { browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://console.anthropic.com/settings/billing", subscriptionDashboardURL: "https://claude.ai/settings/usage", + changelogURL: "https://github.com/anthropics/claude-code/releases", statusPageURL: "https://status.claude.com/"), branding: ProviderBranding( iconStyle: .claude, @@ -33,7 +34,7 @@ public enum ClaudeProviderDescriptor { supportsTokenCost: true, noDataMessage: self.noDataMessage), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web, .cli, .oauth], + sourceModes: [.auto, .api, .web, .cli, .oauth], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "claude", @@ -43,7 +44,12 @@ public enum ClaudeProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - guard context.sourceMode != .api else { return [] } + if context.sourceMode == .api || self.hasAutoAdminAPIKey(context: context) { + return [ClaudeAdminAPIFetchStrategy()] + } + if ClaudeAdminAPIFetchStrategy.isSelectedAdminAPIAccount(context: context) { + return [ClaudeAdminAPIFetchStrategy()] + } let planningInput = await Self.makePlanningInput(context: context) let plan = ClaudeSourcePlanner.resolve(input: planningInput) @@ -51,6 +57,8 @@ public enum ClaudeProviderDescriptor { return plan.orderedSteps.map { step in let strategy: any ProviderFetchStrategy = switch step.dataSource { + case .api: + ClaudeAdminAPIFetchStrategy() case .oauth: ClaudeOAuthFetchStrategy() case .web: @@ -68,8 +76,14 @@ public enum ClaudeProviderDescriptor { } } + private static func hasAutoAdminAPIKey(context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto && ClaudeAdminAPISettingsReader.apiKey(environment: context.env) != nil + } + private static func makePlanningInput(context: ProviderFetchContext) async -> ClaudeSourcePlanningInput { let webExtrasEnabled = context.settings?.claude?.webExtrasEnabled ?? false + let needsOAuthAvailability = context.runtime == .app && context.sourceMode == .auto + return ClaudeSourcePlanningInput( runtime: context.runtime, selectedDataSource: Self.sourceDataSource(from: context.sourceMode), @@ -78,7 +92,7 @@ public enum ClaudeProviderDescriptor { context: context, browserDetection: context.browserDetection), hasCLI: ClaudeCLIResolver.isAvailable(environment: context.env), - hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable( + hasOAuthCredentials: needsOAuthAvailability && ClaudeOAuthPlanningAvailability.isAvailable( runtime: context.runtime, sourceMode: context.sourceMode, environment: context.env)) @@ -112,8 +126,10 @@ public enum ClaudeProviderDescriptor { private static func sourceDataSource(from mode: ProviderSourceMode) -> ClaudeUsageDataSource { switch mode { - case .auto, .api: + case .auto: .auto + case .api: + .api case .web: .web case .cli: @@ -170,6 +186,49 @@ private struct ClaudePlannedFetchStrategy: ProviderFetchStrategy { } } +struct ClaudeAdminAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "claude.admin-api" + let kind: ProviderFetchKind = .apiToken + let usageFetcher: @Sendable (String) async throws -> ClaudeAdminAPIUsageSnapshot + + init( + usageFetcher: @escaping @Sendable (String) async throws -> ClaudeAdminAPIUsageSnapshot = { apiKey in + try await ClaudeAdminAPIUsageFetcher.fetchUsage(apiKey: apiKey) + }) + { + self.usageFetcher = usageFetcher + } + + static func isSelectedAdminAPIAccount(context: ProviderFetchContext) -> Bool { + guard context.selectedTokenAccountID != nil else { return false } + return self.resolveToken(environment: context.env) != nil + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw ClaudeAdminAPISettingsError.missingToken + } + let usage = try await self.usageFetcher(apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "admin-api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.runtime == .app && + context.sourceMode == .auto && + !Self.isSelectedAdminAPIAccount(context: context) + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.claudeAdminAPIToken(environment: environment) + } +} + struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "claude.oauth" let kind: ProviderFetchKind = .oauth @@ -203,6 +262,13 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { sourceMode: ProviderSourceMode, environment: [String: String]) -> Bool { + let hasEnvironmentOAuthToken = !(environment[ClaudeOAuthCredentialsStore.environmentTokenKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty ?? true) + if hasEnvironmentOAuthToken { + return true + } + let strategy = ClaudeOAuthFetchStrategy() let nonInteractiveRecord = strategy.loadNonInteractiveCredentialRecord(environment: environment) let nonInteractiveCredentials = nonInteractiveRecord?.credentials @@ -211,15 +277,8 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { return true } - let hasEnvironmentOAuthToken = !(environment[ClaudeOAuthCredentialsStore.environmentTokenKey]? - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) let claudeCLIAvailable = strategy.isClaudeCLIAvailable(environment: environment) - if hasEnvironmentOAuthToken { - return true - } - if let nonInteractiveRecord, hasRequiredScopeWithoutPrompt, nonInteractiveRecord.credentials.isExpired { switch nonInteractiveRecord.owner { case .codexbar: @@ -299,8 +358,9 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { accountEmail: usage.accountEmail, accountOrganization: usage.accountOrganization, loginMethod: usage.loginMethod) + let primary = usage.primaryWindowKind == .spendLimit ? nil : usage.primary return UsageSnapshot( - primary: usage.primary, + primary: primary, secondary: usage.secondary, tertiary: usage.opus, extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, @@ -308,6 +368,10 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { updatedAt: usage.updatedAt, identity: identity) } + + static func _snapshotForTesting(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { + self.snapshot(from: usage) + } } struct ClaudeWebFetchStrategy: ProviderFetchStrategy { @@ -324,7 +388,8 @@ struct ClaudeWebFetchStrategy: ProviderFetchStrategy { browserDetection: browserDetection, dataSource: .web, useWebExtras: false, - manualCookieHeader: Self.manualCookieHeader(from: context)) + manualCookieHeader: Self.manualCookieHeader(from: context), + webOrganizationID: context.settings?.claude?.organizationID) let usage = try await fetcher.loadLatestUsage(model: "sonnet") return self.makeResult( usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), @@ -375,6 +440,7 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { dataSource: .cli, useWebExtras: self.useWebExtras, manualCookieHeader: self.manualCookieHeader, + webOrganizationID: context.settings?.claude?.organizationID, keepCLISessionsAlive: keepAlive) let usage = try await fetcher.loadLatestUsage(model: "sonnet") return self.makeResult( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift index e440e57ef..24ae2c0c1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeSourcePlanner.swift @@ -71,7 +71,7 @@ public struct ClaudeFetchPlan: Equatable, Sendable { switch self.input.selectedDataSource { case .auto: self.availableSteps.first - case .oauth, .web, .cli: + case .api, .oauth, .web, .cli: self.orderedSteps.first } } @@ -80,7 +80,7 @@ public struct ClaudeFetchPlan: Equatable, Sendable { switch self.input.selectedDataSource { case .auto: self.availableSteps - case .oauth, .web, .cli: + case .api, .oauth, .web, .cli: self.orderedSteps } } @@ -184,6 +184,8 @@ public enum ClaudeSourcePlanner { self.step(.cli, reason: .cliAutoFallbackCLI, input: input), ] } + case .api: + [self.step(.api, reason: .explicitSourceSelection, input: input)] case .oauth: [self.step(.oauth, reason: .explicitSourceSelection, input: input)] case .web: @@ -209,7 +211,7 @@ public enum ClaudeSourcePlanner { input: ClaudeSourcePlanningInput) -> Bool { switch dataSource { - case .auto: + case .auto, .api: false case .oauth: input.hasOAuthCredentials diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index 20a56e502..8188b1165 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -806,11 +806,6 @@ public struct ClaudeStatusProbe: Sendable { private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { let stopOnSubstrings = subcommand == "/usage" ? [ - "Current week (all models)", - "Current week (Opus)", - "Current week (Sonnet only)", - "Current week (Sonnet)", - "Current session", "Failed to load usage data", "failed to load usage data", "Failedto loadusagedata", @@ -819,25 +814,38 @@ public struct ClaudeStatusProbe: Sendable { : [] let idleTimeout: TimeInterval? = subcommand == "/usage" ? nil : 3.0 let sendEnterEvery: TimeInterval? = subcommand == "/usage" ? 0.8 : nil + let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" + ? { @Sendable normalizedScan in + Self.usageCaptureHasSessionValue(normalizedScan) + } + : nil do { - return try await ClaudeCLISession.shared.capture( + return try await ClaudeCLISession.current.capture( subcommand: subcommand, binary: binary, timeout: timeout, idleTimeout: idleTimeout, stopOnSubstrings: stopOnSubstrings, + stopWhenNormalized: stopWhenNormalized, settleAfterStop: subcommand == "/usage" ? 2.0 : 0.25, sendEnterEvery: sendEnterEvery) } catch ClaudeCLISession.SessionError.processExited { - await ClaudeCLISession.shared.reset() + await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.timedOut { + await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.launchFailed(_) { throw ClaudeStatusProbeError.claudeNotInstalled } catch { - await ClaudeCLISession.shared.reset() + await ClaudeCLISession.current.reset() throw error } } + + private static func usageCaptureHasSessionValue(_ normalizedText: String) -> Bool { + guard let labelRange = normalizedText.range(of: "currentsession") else { return false } + let tail = normalizedText[labelRange.upperBound...] + return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift index b97dce042..063de7ffa 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageDataSource.swift @@ -2,6 +2,7 @@ import Foundation public enum ClaudeUsageDataSource: String, CaseIterable, Identifiable, Sendable { case auto + case api case oauth case web case cli @@ -13,6 +14,7 @@ public enum ClaudeUsageDataSource: String, CaseIterable, Identifiable, Sendable public var displayName: String { switch self { case .auto: "Auto" + case .api: "API (Admin key)" case .oauth: "OAuth API" case .web: "Web API (cookies)" case .cli: "CLI (PTY)" @@ -23,6 +25,8 @@ public enum ClaudeUsageDataSource: String, CaseIterable, Identifiable, Sendable switch self { case .auto: "auto" + case .api: + "api" case .oauth: "oauth" case .web: diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 82d919865..9da973423 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -7,7 +7,13 @@ public protocol ClaudeUsageFetching: Sendable { } public struct ClaudeUsageSnapshot: Sendable { + public enum PrimaryWindowKind: Equatable, Sendable { + case usage + case spendLimit + } + public let primary: RateWindow + public let primaryWindowKind: PrimaryWindowKind public let secondary: RateWindow? public let opus: RateWindow? public let extraRateWindows: [NamedRateWindow] @@ -20,6 +26,7 @@ public struct ClaudeUsageSnapshot: Sendable { public init( primary: RateWindow, + primaryWindowKind: PrimaryWindowKind = .usage, secondary: RateWindow?, opus: RateWindow?, extraRateWindows: [NamedRateWindow] = [], @@ -31,6 +38,7 @@ public struct ClaudeUsageSnapshot: Sendable { rawText: String?) { self.primary = primary + self.primaryWindowKind = primaryWindowKind self.secondary = secondary self.opus = opus self.extraRateWindows = extraRateWindows @@ -63,6 +71,8 @@ public enum ClaudeUsageError: LocalizedError, Sendable { public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let cliProbeTimeout: TimeInterval = 24 + private static let cliRetryProbeTimeout: TimeInterval = 60 private struct Configuration { let environment: [String: String] let runtime: ProviderRuntime @@ -72,6 +82,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let allowStartupBootstrapPrompt: Bool let useWebExtras: Bool let manualCookieHeader: String? + let webOrganizationID: String? let keepCLISessionsAlive: Bool let browserDetection: BrowserDetection } @@ -114,6 +125,10 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { self.configuration.manualCookieHeader } + private var webOrganizationID: String? { + self.configuration.webOrganizationID + } + private var keepCLISessionsAlive: Bool { self.configuration.keepCLISessionsAlive } @@ -218,6 +233,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { allowStartupBootstrapPrompt: Bool = false, useWebExtras: Bool = false, manualCookieHeader: String? = nil, + webOrganizationID: String? = nil, keepCLISessionsAlive: Bool = false) { self.configuration = Configuration( @@ -229,6 +245,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { allowStartupBootstrapPrompt: allowStartupBootstrapPrompt, useWebExtras: useWebExtras, manualCookieHeader: manualCookieHeader, + webOrganizationID: webOrganizationID, keepCLISessionsAlive: keepCLISessionsAlive, browserDetection: browserDetection) } @@ -241,8 +258,13 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() #if DEBUG - let hasCache = ClaudeUsageFetcher.hasCachedCredentialsOverride - ?? ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) + let hasCache = if let hasCachedCredentialsOverride = ClaudeUsageFetcher.hasCachedCredentialsOverride { + hasCachedCredentialsOverride + } else if ClaudeUsageFetcher.loadOAuthCredentialsOverride != nil { + false + } else { + ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) + } #else let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment) #endif @@ -435,6 +457,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { switch self.fetcher.dataSource { case .auto: return try await self.executeAuto(model: model) + case .api: + throw ClaudeUsageError.parseFailed("Claude Admin API usage is handled by the provider descriptor.") case .oauth: var snapshot = try await self.fetcher.loadViaOAuth(allowDelegatedRetry: true) snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) @@ -442,15 +466,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .web: return try await self.fetcher.loadViaWebAPI() case .cli: - do { - var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: 10) - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) - return snapshot - } catch { - var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: 24) - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) - return snapshot - } + return try await self.loadViaCLIWithRetry(model: model) } } @@ -516,6 +532,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot { switch step.dataSource { + case .api: + throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.") case .oauth: var snapshot = try await self.fetcher.loadViaOAuth(allowDelegatedRetry: true) snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) @@ -523,13 +541,33 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .web: return try await self.fetcher.loadViaWebAPI() case .cli: - var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: 10) - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) - return snapshot + return try await self.loadViaCLIWithRetry(model: model) case .auto: throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.") } } + + private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot { + do { + return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout) + } catch { + if error is CancellationError { throw error } + guard Self.shouldRetryCLIProbe(after: error) else { throw error } + return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout) + } + } + + private func loadViaCLI(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { + var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) + snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) + return snapshot + } + + private static func shouldRetryCLIProbe(after error: Error) -> Bool { + if case ClaudeStatusProbeError.timedOut = error { return true } + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || message.contains("timeout") + } } } @@ -645,7 +683,7 @@ extension ClaudeUsageFetcher { public func debugRawProbe(model: String = "sonnet") async -> String { do { - let snap = try await self.loadViaPTY(model: model, timeout: 10) + let snap = try await self.loadViaPTY(model: model, timeout: Self.cliProbeTimeout) let opus = snap.opus?.remainingPercent ?? -1 let email = snap.accountEmail ?? "nil" let org = snap.accountOrganization ?? "nil" @@ -836,7 +874,35 @@ extension ClaudeUsageFetcher { resetDescription: resetDescription) } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) else { + let loginMethod = ClaudePlan.oauthLoginMethod( + subscriptionType: credentials.subscriptionType, + rateLimitTier: credentials.rateLimitTier) + let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) + ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + let treatAsSpendLimit = primary == nil && usage.extraUsage?.isEnabled == true + let providerCost = Self.oauthExtraUsageCost( + usage.extraUsage, + loginMethod: loginMethod, + treatAsSpendLimit: treatAsSpendLimit) + + guard let primary else { + if let spendLimit = Self.oauthSpendLimitWindow(from: providerCost, extraUsage: usage.extraUsage) { + return ClaudeUsageSnapshot( + primary: spendLimit, + primaryWindowKind: .spendLimit, + secondary: nil, + opus: nil, + extraRateWindows: Self.oauthExtraRateWindows(from: usage), + providerCost: providerCost, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod, + rawText: nil) + } throw ClaudeUsageError.parseFailed("missing session data") } @@ -846,9 +912,6 @@ extension ClaudeUsageFetcher { windowMinutes: 7 * 24 * 60) let extraRateWindows = Self.oauthExtraRateWindows(from: usage) - let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier) - let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) - return ClaudeUsageSnapshot( primary: primary, secondary: weekly, @@ -864,7 +927,8 @@ extension ClaudeUsageFetcher { private static func oauthExtraUsageCost( _ extra: OAuthExtraUsage?, - loginMethod: String?) -> ProviderCostSnapshot? + loginMethod: String?, + treatAsSpendLimit: Bool = false) -> ProviderCostSnapshot? { guard let extra, extra.isEnabled == true else { return nil } guard let used = extra.usedCredits, @@ -872,24 +936,50 @@ extension ClaudeUsageFetcher { else { return nil } let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines) let code = (currency?.isEmpty ?? true) ? "USD" : currency! - let normalized = Self.normalizeClaudeExtraUsageAmounts(used: used, limit: limit) + let isSpendLimit = treatAsSpendLimit || ClaudePlan.fromCompatibilityLoginMethod(loginMethod) == .enterprise + let normalized = Self.normalizeClaudeExtraUsageAmounts( + used: used, + limit: limit, + treatAsMajorUnits: isSpendLimit) return ProviderCostSnapshot( used: normalized.used, limit: normalized.limit, currencyCode: code, - period: "Monthly", + period: isSpendLimit ? "Spend limit" : "Monthly cap", resetsAt: nil, updatedAt: Date()) } + private static func oauthSpendLimitWindow( + from providerCost: ProviderCostSnapshot?, + extraUsage: OAuthExtraUsage?) -> RateWindow? + { + guard let providerCost, + providerCost.limit > 0 + else { return nil } + let usedPercent = extraUsage?.utilization ?? (providerCost.used / providerCost.limit) * 100 + let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode) + let limit = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode) + return RateWindow( + usedPercent: min(100, max(0, usedPercent)), + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: "\(providerCost.period ?? "Spend limit"): \(used) / \(limit)") + } + private static func normalizeClaudeExtraUsageAmounts( used: Double, - limit: Double) -> (used: Double, limit: Double) + limit: Double, + treatAsMajorUnits: Bool) -> (used: Double, limit: Double) { + if treatAsMajorUnits { + return (used: used, limit: limit) + } + // Claude's OAuth API returns values in cents (minor units), same as the Web API. // Always convert to dollars (major units) for display consistency. // See: ClaudeWebAPIFetcher.swift which always divides by 100. - (used: used / 100.0, limit: limit / 100.0) + return (used: used / 100.0, limit: limit / 100.0) } private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { @@ -941,11 +1031,17 @@ extension ClaudeUsageFetcher { private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot { let webData: ClaudeWebAPIFetcher.WebUsageData = if let header = self.manualCookieHeader { - try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: header) { msg in + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: header, + targetOrganizationID: self.webOrganizationID) + { msg in Self.log.debug(msg) } } else { - try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: self.browserDetection) { msg in + try await ClaudeWebAPIFetcher.fetchUsage( + browserDetection: self.browserDetection, + targetOrganizationID: self.webOrganizationID) + { msg in Self.log.debug(msg) } } @@ -1050,12 +1146,16 @@ extension ClaudeUsageFetcher { do { let webData: ClaudeWebAPIFetcher.WebUsageData = if let header = self.manualCookieHeader { - try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: header) { msg in + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: header, + targetOrganizationID: self.webOrganizationID) + { msg in Self.log.debug(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage( - browserDetection: self.browserDetection) + browserDetection: self.browserDetection, + targetOrganizationID: self.webOrganizationID) { msg in Self.log.debug(msg) } @@ -1067,6 +1167,7 @@ extension ClaudeUsageFetcher { if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, + primaryWindowKind: snapshot.primaryWindowKind, secondary: snapshot.secondary, opus: snapshot.opus, extraRateWindows: mergedExtraRateWindows, @@ -1148,7 +1249,8 @@ extension ClaudeUsageFetcher { extension ClaudeUsageFetcher { public static func _mapOAuthUsageForTesting( _ data: Data, - rateLimitTier: String? = nil) throws -> ClaudeUsageSnapshot + rateLimitTier: String? = nil, + subscriptionType: String? = nil) throws -> ClaudeUsageSnapshot { let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data) let creds = ClaudeOAuthCredentials( @@ -1156,7 +1258,8 @@ extension ClaudeUsageFetcher { refreshToken: nil, expiresAt: Date().addingTimeInterval(3600), scopes: [], - rateLimitTier: rateLimitTier) + rateLimitTier: rateLimitTier, + subscriptionType: subscriptionType) return try Self.mapOAuthUsage(usage, credentials: creds) } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 6fb3f41ea..d88505db4 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -54,6 +54,7 @@ public enum ClaudeWebAPIFetcher { case unauthorized case serverError(statusCode: Int) case noOrganization + case organizationNotFound(String) public var errorDescription: String? { switch self { @@ -73,6 +74,8 @@ public enum ClaudeWebAPIFetcher { "Claude API error: HTTP \(code)" case .noOrganization: "No Claude organization found for this account." + case let .organizationNotFound(id): + "Claude organization '\(id)' was not found for this session." } } } @@ -134,6 +137,7 @@ public enum ClaudeWebAPIFetcher { /// Tries browser cookies using the standard import order. public static func fetchUsage( browserDetection: BrowserDetection, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") } @@ -143,7 +147,10 @@ public enum ClaudeWebAPIFetcher { { log("Using cached cookie header from \(cached.sourceLabel)") do { - return try await self.fetchUsage(cookieHeader: cached.cookieHeader, logger: log) + return try await self.fetchUsage( + cookieHeader: cached.cookieHeader, + targetOrganizationID: targetOrganizationID, + logger: log) } catch let error as FetchError { switch error { case .unauthorized, .noSessionKeyFound, .invalidSessionKey: @@ -159,7 +166,10 @@ public enum ClaudeWebAPIFetcher { let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log) log("Found session key (\(sessionInfo.cookieCount) cookies)") - let usage = try await self.fetchUsage(using: sessionInfo, logger: log) + let usage = try await self.fetchUsage( + using: sessionInfo, + targetOrganizationID: targetOrganizationID, + logger: log) CookieHeaderCache.store( provider: .claude, cookieHeader: "sessionKey=\(sessionInfo.key)", @@ -169,28 +179,40 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( cookieHeader: String, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") } let sessionInfo = try self.sessionKeyInfo(cookieHeader: cookieHeader) log("Using manual session key (\(sessionInfo.cookieCount) cookies)") - return try await self.fetchUsage(using: sessionInfo, logger: log) + return try await self.fetchUsage( + using: sessionInfo, + targetOrganizationID: targetOrganizationID, + logger: log) } public static func fetchUsage( using sessionKeyInfo: SessionKeyInfo, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?(msg) } let sessionKey = sessionKeyInfo.key // Fetch organization info - let organization = try await fetchOrganizationInfo(sessionKey: sessionKey, logger: log) + let organization = try await fetchOrganizationInfo( + sessionKey: sessionKey, + targetOrganizationID: targetOrganizationID, + logger: log) log("Organization resolved") var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey, logger: log) if usage.extraUsageCost == nil, - let extra = await fetchExtraUsageCost(orgId: organization.id, sessionKey: sessionKey, logger: log) + let extra = await ClaudeWebExtraUsageCost.fetch( + baseURL: Self.baseURL, + orgId: organization.id, + sessionKey: sessionKey, + logger: log) { usage = WebUsageData( sessionPercentUsed: usage.sessionPercentUsed, @@ -270,7 +292,7 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 20 do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) let http = response as? HTTPURLResponse let contentType = http?.allHeaderFields["Content-Type"] as? String let truncated = data.prefix(Self.maxProbeBytes) @@ -396,6 +418,7 @@ public enum ClaudeWebAPIFetcher { private static func fetchOrganizationInfo( sessionKey: String, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> OrganizationInfo { let url = URL(string: "\(baseURL)/organizations")! @@ -405,7 +428,7 @@ public enum ClaudeWebAPIFetcher { request.httpMethod = "GET" request.timeoutInterval = 15 - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse @@ -415,7 +438,7 @@ public enum ClaudeWebAPIFetcher { switch httpResponse.statusCode { case 200: - return try self.parseOrganizationResponse(data) + return try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID) case 401, 403: throw FetchError.unauthorized default: @@ -435,7 +458,7 @@ public enum ClaudeWebAPIFetcher { request.httpMethod = "GET" request.timeoutInterval = 15 - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse @@ -469,10 +492,8 @@ public enum ClaudeWebAPIFetcher { sessionResets = self.parseISO8601Date(resetsAt) } } - guard let sessionPercent else { - // If we can't parse session utilization, treat this as a failure so callers can fall back to the CLI. - throw FetchError.invalidResponse - } + // Enterprise/credit-based accounts return null for five_hour; treat as 0% rather than an error. + let resolvedSessionPercent = sessionPercent ?? 0.0 // Parse seven_day (weekly) usage var weeklyPercent: Double? @@ -500,15 +521,16 @@ public enum ClaudeWebAPIFetcher { if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { logger?("Usage API extra window key matched: routines=\(sourceKey)") } + let extraUsageCost = ClaudeWebExtraUsageCost.parse(from: json["extra_usage"]) return WebUsageData( - sessionPercentUsed: sessionPercent, + sessionPercentUsed: resolvedSessionPercent, sessionResetsAt: sessionResets, weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, opusPercentUsed: opusPercent, extraRateWindows: extraRateParse.windows, - extraUsageCost: nil, + extraUsageCost: extraUsageCost, accountOrganization: nil, accountEmail: nil, loginMethod: nil) @@ -524,66 +546,6 @@ public enum ClaudeWebAPIFetcher { return nil } - // MARK: - Extra usage cost (Claude "Extra") - - private struct OverageSpendLimitResponse: Decodable { - let monthlyCreditLimit: Double? - let currency: String? - let usedCredits: Double? - let isEnabled: Bool? - - enum CodingKeys: String, CodingKey { - case monthlyCreditLimit = "monthly_credit_limit" - case currency - case usedCredits = "used_credits" - case isEnabled = "is_enabled" - } - } - - /// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch). - private static func fetchExtraUsageCost( - orgId: String, - sessionKey: String, - logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot? - { - let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")! - var request = URLRequest(url: url) - request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpMethod = "GET" - request.timeoutInterval = 15 - - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return nil } - logger?("Overage API status: \(httpResponse.statusCode)") - guard httpResponse.statusCode == 200 else { return nil } - return Self.parseOverageSpendLimit(data) - } catch { - return nil - } - } - - private static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? { - guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil } - guard decoded.isEnabled == true else { return nil } - guard let used = decoded.usedCredits, - let limit = decoded.monthlyCreditLimit, - let currency = decoded.currency, - !currency.isEmpty else { return nil } - - let usedAmount = used / 100.0 - let limitAmount = limit / 100.0 - - return ProviderCostSnapshot( - used: usedAmount, - limit: limitAmount, - currencyCode: currency, - period: "Monthly", - resetsAt: nil, - updatedAt: Date()) - } - #if DEBUG // MARK: - Test hooks (DEBUG-only) @@ -592,12 +554,15 @@ public enum ClaudeWebAPIFetcher { try self.parseUsageResponse(data) } - public static func _parseOrganizationsResponseForTesting(_ data: Data) throws -> OrganizationInfo { - try self.parseOrganizationResponse(data) + public static func _parseOrganizationsResponseForTesting( + _ data: Data, + targetOrganizationID: String? = nil) throws -> OrganizationInfo + { + try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID) } public static func _parseOverageSpendLimitForTesting(_ data: Data) -> ProviderCostSnapshot? { - self.parseOverageSpendLimit(data) + ClaudeWebExtraUsageCost.parseOverageSpendLimit(data) } public static func _parseAccountInfoForTesting(_ data: Data, orgId: String?) -> WebAccountInfo? { @@ -616,35 +581,31 @@ public enum ClaudeWebAPIFetcher { return formatter.date(from: string) } - private struct OrganizationResponse: Decodable { - let uuid: String - let name: String? - let capabilities: [String]? - - var normalizedCapabilities: Set { - Set((self.capabilities ?? []).map { $0.lowercased() }) - } - - var hasChatCapability: Bool { - self.normalizedCapabilities.contains("chat") - } - - var isApiOnly: Bool { - let normalized = self.normalizedCapabilities - return !normalized.isEmpty && normalized == ["api"] - } - } - - private static func parseOrganizationResponse(_ data: Data) throws -> OrganizationInfo { - guard let organizations = try? JSONDecoder().decode([OrganizationResponse].self, from: data) else { + private static func parseOrganizationResponse( + _ data: Data, + targetOrganizationID: String? = nil) throws -> OrganizationInfo + { + guard let organizations = try? JSONDecoder().decode([ClaudeWebOrganizationResponse].self, from: data) else { throw FetchError.invalidResponse } + if let targetOrganizationID = targetOrganizationID?.trimmingCharacters(in: .whitespacesAndNewlines), + !targetOrganizationID.isEmpty + { + guard let selected = organizations.first(where: { $0.uuid == targetOrganizationID }) else { + throw FetchError.organizationNotFound(targetOrganizationID) + } + return self.organizationInfo(from: selected) + } guard let selected = organizations.first(where: { $0.hasChatCapability }) ?? organizations.first(where: { !$0.isApiOnly }) ?? organizations.first else { throw FetchError.noOrganization } + return self.organizationInfo(from: selected) + } + + private static func organizationInfo(from selected: ClaudeWebOrganizationResponse) -> OrganizationInfo { let name = selected.name?.trimmingCharacters(in: .whitespacesAndNewlines) let sanitized = (name?.isEmpty ?? true) ? nil : name return OrganizationInfo(id: selected.uuid, name: sanitized) @@ -701,7 +662,7 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 15 do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { return nil } logger?("Account API status: \(httpResponse.statusCode)") guard httpResponse.statusCode == 200 else { return nil } @@ -854,26 +815,32 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( browserDetection: BrowserDetection, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { _ = browserDetection + _ = targetOrganizationID _ = logger throw FetchError.notSupportedOnThisPlatform } public static func fetchUsage( cookieHeader: String, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { _ = cookieHeader + _ = targetOrganizationID _ = logger throw FetchError.notSupportedOnThisPlatform } public static func fetchUsage( using sessionKeyInfo: SessionKeyInfo, + targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { + _ = targetOrganizationID throw FetchError.notSupportedOnThisPlatform } @@ -908,3 +875,122 @@ public enum ClaudeWebAPIFetcher { #endif } + +private enum ClaudeWebExtraUsageCost { + // MARK: - Extra usage cost (Claude "Extra") + + static func parse(from value: Any?) -> ProviderCostSnapshot? { + guard let extraUsage = value as? [String: Any] else { return nil } + guard let used = Self.doubleValue(extraUsage["used_credits"]), + let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]), + limit > 0 else { return nil } + let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD" + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currencyCode) + } + + struct OverageSpendLimitResponse: Decodable { + let monthlyCreditLimit: Double? + let currency: String? + let usedCredits: Double? + let isEnabled: Bool? + + enum CodingKeys: String, CodingKey { + case monthlyCreditLimit = "monthly_credit_limit" + case currency + case usedCredits = "used_credits" + case isEnabled = "is_enabled" + } + } + + /// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch). + static func fetch( + baseURL: String, + orgId: String, + sessionKey: String, + logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot? + { + let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")! + var request = URLRequest(url: url) + request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpMethod = "GET" + request.timeoutInterval = 15 + + do { + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return nil } + logger?("Overage API status: \(httpResponse.statusCode)") + guard httpResponse.statusCode == 200 else { return nil } + return Self.parseOverageSpendLimit(data) + } catch { + return nil + } + } + + static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? { + guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil } + guard decoded.isEnabled == true else { return nil } + guard let used = decoded.usedCredits, + let limit = decoded.monthlyCreditLimit, + let currency = decoded.currency, + !currency.isEmpty else { return nil } + + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currency) + } + + static func makeExtraUsageCost( + usedCredits: Double, + monthlyCreditLimit: Double, + currencyCode: String) -> ProviderCostSnapshot + { + let usedAmount = usedCredits / 100.0 + let limitAmount = monthlyCreditLimit / 100.0 + + return ProviderCostSnapshot( + used: usedAmount, + limit: limitAmount, + currencyCode: currencyCode, + period: "Monthly cap", + resetsAt: nil, + updatedAt: Date()) + } + + static func doubleValue(_ value: Any?) -> Double? { + switch value { + case let int as Int: + Double(int) + case let double as Double: + double + case let string as String: + Double(string) + default: + nil + } + } +} + +private struct ClaudeWebOrganizationResponse: Decodable { + let uuid: String + let name: String? + let capabilities: [String]? + + var normalizedCapabilities: Set { + Set((self.capabilities ?? []).map { $0.lowercased() }) + } + + var hasChatCapability: Bool { + self.normalizedCapabilities.contains("chat") + } + + var isApiOnly: Bool { + let normalized = self.normalizedCapabilities + return !normalized.isEmpty && normalized == ["api"] + } +} diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift new file mode 100644 index 000000000..8ea43102f --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffProviderDescriptor.swift @@ -0,0 +1,93 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CodebuffProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .codebuff, + metadata: ProviderMetadata( + id: .codebuff, + displayName: "Codebuff", + sessionLabel: "Credits", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Credit balance from the Codebuff API", + toggleTitle: "Show Codebuff usage", + cliName: "codebuff", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://www.codebuff.com/usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .codebuff, + iconResourceName: "ProviderIcon-codebuff", + color: ProviderColor(red: 68 / 255, green: 255 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Codebuff cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodebuffAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "codebuff", + aliases: ["manicode"], + versionDetector: nil)) + } +} + +struct CodebuffAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "codebuff.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + _ = context + // Keep the strategy available so missing-token surfaces as a user-friendly error + // instead of a generic "no strategy" outcome. + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let resolution = Self.resolveToken(environment: context.env) else { + throw CodebuffUsageError.missingCredentials + } + let usage = try await CodebuffUsageFetcher.fetchUsage( + apiKey: resolution.token, + environment: context.env, + includeSubscription: Self.shouldFetchSubscription(for: resolution)) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + static func shouldFetchSubscription(for resolution: ProviderTokenResolution) -> Bool { + resolution.source == .authFile + } + + private static func resolveToken(environment: [String: String]) -> ProviderTokenResolution? { + ProviderTokenResolver.codebuffResolution(environment: environment) + } +} + +/// Errors related to Codebuff settings. +public enum CodebuffSettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " + + "populate ~/.config/manicode/credentials.json." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift new file mode 100644 index 000000000..dbfe85eeb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffSettingsReader.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Reads Codebuff settings from the environment or the local credentials file +/// that the `codebuff` CLI (formerly `manicode`) writes when the user logs in. +public enum CodebuffSettingsReader { + /// Environment variable key for the Codebuff API token. + public static let apiTokenKey = "CODEBUFF_API_KEY" + + /// Returns the API token from environment if present and non-empty. + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.apiTokenKey]) + } + + /// Returns the API base URL, defaulting to the production endpoint. + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment["CODEBUFF_API_URL"], + let url = URL(string: cleaned(override) ?? "") + { + return url + } + return URL(string: "https://www.codebuff.com")! + } + + /// Returns the auth token from the local credentials file if present. + public static func authToken( + authFileURL: URL? = nil, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> String? + { + let fileURL = authFileURL ?? self.defaultAuthFileURL(homeDirectory: homeDirectory) + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return self.parseAuthToken(data: data) + } + + /// Default on-disk credentials path: `~/.config/manicode/credentials.json`. + static func defaultAuthFileURL(homeDirectory: URL) -> URL { + homeDirectory + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("manicode", isDirectory: true) + .appendingPathComponent("credentials.json", isDirectory: false) + } + + static func parseAuthToken(data: Data) -> String? { + guard let payload = try? JSONDecoder().decode(CredentialsFile.self, from: data) else { + return nil + } + return self.cleaned(payload.default?.authToken) ?? self.cleaned(payload.authToken) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +private struct CredentialsFile: Decodable { + let `default`: CredentialsProfile? + let authToken: String? +} + +private struct CredentialsProfile: Decodable { + let authToken: String? + let fingerprintId: String? + let email: String? + let name: String? +} diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift new file mode 100644 index 000000000..729d17f89 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageError.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum CodebuffUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case unauthorized + case endpointNotFound + case serviceUnavailable(Int) + case apiError(Int) + case networkError(String) + case parseFailed(String) + + public static let missingToken: CodebuffUsageError = .missingCredentials + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Codebuff API token not configured. Set CODEBUFF_API_KEY or run `codebuff login` to " + + "populate ~/.config/manicode/credentials.json." + case .unauthorized: + "Unauthorized. Please sign in to Codebuff again." + case .endpointNotFound: + "Codebuff usage endpoint not found." + case let .serviceUnavailable(status): + "Codebuff API is temporarily unavailable (status \(status))." + case let .apiError(status): + "Codebuff API returned an unexpected status (\(status))." + case let .networkError(message): + "Codebuff API error: \(message)" + case let .parseFailed(message): + "Could not parse Codebuff usage: \(message)" + } + } + + public var isAuthRelated: Bool { + switch self { + case .unauthorized, .missingCredentials: true + default: false + } + } +} diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift new file mode 100644 index 000000000..bd8062ad6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageFetcher.swift @@ -0,0 +1,345 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Fetches live credit balance and subscription details from the Codebuff API. +/// Uses Bearer token auth against the public www.codebuff.com endpoints used by +/// the dashboard + the `codebuff` CLI. +public enum CodebuffUsageFetcher { + private static let requestTimeoutSeconds: TimeInterval = 15 + /// Extra grace period to wait for the optional subscription endpoint after the + /// primary usage call returns. Keeps the menu responsive when `/api/user/subscription` + /// is slow or hangs while `/api/v1/usage` succeeds quickly. + private static let subscriptionGraceSeconds: TimeInterval = 2 + + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + includeSubscription: Bool = true, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> CodebuffUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CodebuffUsageError.missingCredentials + } + + let baseURL = CodebuffSettingsReader.apiURL(environment: environment) + + let (usageValues, subscriptionValues) = try await self.fetchPayloads( + apiKey: trimmed, + baseURL: baseURL, + includeSubscription: includeSubscription, + transport: transport) + + return CodebuffUsageSnapshot( + creditsUsed: usageValues.used, + creditsTotal: usageValues.total, + creditsRemaining: usageValues.remaining, + weeklyUsed: subscriptionValues?.weeklyUsed, + weeklyLimit: subscriptionValues?.weeklyLimit, + weeklyResetsAt: subscriptionValues?.weeklyResetsAt, + billingPeriodEnd: subscriptionValues?.billingPeriodEnd, + nextQuotaReset: usageValues.nextQuotaReset, + tier: subscriptionValues?.tier, + subscriptionStatus: subscriptionValues?.status, + autoTopUpEnabled: usageValues.autoTopupEnabled, + accountEmail: subscriptionValues?.email, + updatedAt: Date()) + } + + private static func fetchPayloads( + apiKey: String, + baseURL: URL, + includeSubscription: Bool, + transport: any ProviderHTTPTransport) async throws -> (UsagePayload, SubscriptionPayload?) + { + try await withThrowingTaskGroup(of: FetchResult.self) { group in + group.addTask { + try await .usage(self.fetchUsagePayload(apiKey: apiKey, baseURL: baseURL, transport: transport)) + } + if includeSubscription { + group.addTask { + await .subscription(try? self.fetchSubscriptionPayload( + apiKey: apiKey, + baseURL: baseURL, + transport: transport)) + } + } + + var usageValues: UsagePayload? + var subscriptionValues: SubscriptionPayload? + var subscriptionFinished = !includeSubscription + var timeoutStarted = false + + while let result = try await group.next() { + switch result { + case let .usage(payload): + usageValues = payload + if subscriptionFinished { + group.cancelAll() + return (payload, subscriptionValues) + } + if !timeoutStarted { + timeoutStarted = true + group.addTask { + let nanos = UInt64(max(0, Self.subscriptionGraceSeconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanos) + return .subscriptionTimeout + } + } + + case let .subscription(payload): + subscriptionValues = payload + subscriptionFinished = true + if let usageValues { + group.cancelAll() + return (usageValues, payload) + } + + case .subscriptionTimeout: + if let usageValues { + group.cancelAll() + return (usageValues, subscriptionValues) + } + } + } + + throw CodebuffUsageError.networkError("Usage request did not complete") + } + } + + // MARK: - Endpoint helpers + + private enum FetchResult { + case usage(UsagePayload) + case subscription(SubscriptionPayload?) + case subscriptionTimeout + } + + struct UsagePayload { + let used: Double? + let total: Double? + let remaining: Double? + let nextQuotaReset: Date? + let autoTopupEnabled: Bool? + } + + struct SubscriptionPayload { + let status: String? + let tier: String? + let billingPeriodEnd: Date? + let weeklyUsed: Double? + let weeklyLimit: Double? + let weeklyResetsAt: Date? + let email: String? + } + + static func usageURL(baseURL: URL) -> URL { + baseURL.appendingPathComponent("/api/v1/usage") + } + + static func subscriptionURL(baseURL: URL) -> URL { + baseURL.appendingPathComponent("/api/user/subscription") + } + + static func statusError(for statusCode: Int) -> CodebuffUsageError? { + switch statusCode { + case 401, 403: .unauthorized + case 404: .endpointNotFound + case 500...599: .serviceUnavailable(statusCode) + default: nil + } + } + + static func parseUsagePayload(_ data: Data) throws -> UsagePayload { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodebuffUsageError.parseFailed("Invalid JSON") + } + + let used = self.double(from: root["usage"]) ?? self.double(from: root["used"]) + let total = self.double(from: root["quota"]) ?? self.double(from: root["limit"]) + let remaining = self.double(from: root["remainingBalance"]) ?? self.double(from: root["remaining"]) + let reset = self.date(from: root["next_quota_reset"]) + let autoTopUp = root["autoTopupEnabled"] as? Bool ?? root["auto_topup_enabled"] as? Bool + + return UsagePayload( + used: used, + total: total, + remaining: remaining, + nextQuotaReset: reset, + autoTopupEnabled: autoTopUp) + } + + static func parseSubscriptionPayload(_ data: Data) throws -> SubscriptionPayload { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CodebuffUsageError.parseFailed("Invalid JSON") + } + + let subscription = root["subscription"] as? [String: Any] + let rateLimit = root["rateLimit"] as? [String: Any] + + let tier = self.string(from: subscription?["displayName"]) + ?? self.string(from: root["displayName"]) + ?? self.string(from: subscription?["tier"]) + ?? self.string(from: root["tier"]) + ?? self.string(from: subscription?["scheduledTier"]) + let status = subscription?["status"] as? String + let email = root["email"] as? String ?? (root["user"] as? [String: Any])?["email"] as? String + let billingPeriodEnd = self.date(from: subscription?["billingPeriodEnd"]) + ?? self.date(from: subscription?["currentPeriodEnd"]) + let weeklyUsed = self.double(from: rateLimit?["weeklyUsed"]) + ?? self.double(from: rateLimit?["used"]) + let weeklyLimit = self.double(from: rateLimit?["weeklyLimit"]) + ?? self.double(from: rateLimit?["limit"]) + let weeklyResetsAt = self.date(from: rateLimit?["weeklyResetsAt"]) + + return SubscriptionPayload( + status: status, + tier: tier, + billingPeriodEnd: billingPeriodEnd, + weeklyUsed: weeklyUsed, + weeklyLimit: weeklyLimit, + weeklyResetsAt: weeklyResetsAt, + email: email) + } + + // MARK: - Test hooks + + static func _parseUsagePayloadForTesting(_ data: Data) throws -> UsagePayload { + try self.parseUsagePayload(data) + } + + static func _parseSubscriptionPayloadForTesting(_ data: Data) throws -> SubscriptionPayload { + try self.parseSubscriptionPayload(data) + } + + static func _statusErrorForTesting(_ statusCode: Int) -> CodebuffUsageError? { + self.statusError(for: statusCode) + } + + // MARK: - Networking + + private static func fetchUsagePayload( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport) async throws -> UsagePayload + { + var request = URLRequest(url: self.usageURL(baseURL: baseURL)) + request.httpMethod = "POST" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["fingerprintId": "codexbar-usage"]) + + let response = try await self.send(request: request, transport: transport) + if let err = self.statusError(for: response.statusCode) { + throw err + } + guard response.statusCode == 200 else { + throw CodebuffUsageError.apiError(response.statusCode) + } + return try self.parseUsagePayload(response.data) + } + + private static func fetchSubscriptionPayload( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport) async throws -> SubscriptionPayload + { + var request = URLRequest(url: self.subscriptionURL(baseURL: baseURL)) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await self.send(request: request, transport: transport) + if let err = self.statusError(for: response.statusCode) { + throw err + } + guard response.statusCode == 200 else { + throw CodebuffUsageError.apiError(response.statusCode) + } + return try self.parseSubscriptionPayload(response.data) + } + + private static func send( + request: URLRequest, + transport: any ProviderHTTPTransport) async throws -> ProviderHTTPResponse + { + do { + return try await transport.response(for: request) + } catch let error as CodebuffUsageError { + throw error + } catch let error as URLError where error.code == .badServerResponse { + throw CodebuffUsageError.networkError("Invalid response") + } catch { + throw CodebuffUsageError.networkError(error.localizedDescription) + } + } + + // MARK: - Value parsing + + private static func double(from value: Any?) -> Double? { + switch value { + case let number as NSNumber: + let raw = number.doubleValue + return raw.isFinite ? raw : nil + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let raw = Double(trimmed), raw.isFinite else { return nil } + return raw + default: + return nil + } + } + + private static func string(from value: Any?) -> String? { + switch value { + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + case let number as NSNumber: + let raw = number.doubleValue + guard raw.isFinite else { return nil } + return number.stringValue + default: + return nil + } + } + + private static func date(from value: Any?) -> Date? { + switch value { + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: trimmed) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: trimmed) { + return date + } + if let interval = Double(trimmed), interval.isFinite { + return Self.dateFromNumeric(interval) + } + return nil + case let number as NSNumber: + let raw = number.doubleValue + return raw.isFinite ? Self.dateFromNumeric(raw) : nil + default: + return nil + } + } + + private static func dateFromNumeric(_ value: Double) -> Date? { + if value > 10_000_000_000 { + return Date(timeIntervalSince1970: value / 1000) + } + return Date(timeIntervalSince1970: value) + } +} diff --git a/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift new file mode 100644 index 000000000..478cc8e1e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codebuff/CodebuffUsageSnapshot.swift @@ -0,0 +1,147 @@ +import Foundation + +/// Parsed view of a Codebuff usage + subscription response pair. +public struct CodebuffUsageSnapshot: Sendable { + public let creditsUsed: Double? + public let creditsTotal: Double? + public let creditsRemaining: Double? + public let weeklyUsed: Double? + public let weeklyLimit: Double? + public let weeklyResetsAt: Date? + public let billingPeriodEnd: Date? + public let nextQuotaReset: Date? + public let tier: String? + public let subscriptionStatus: String? + public let autoTopUpEnabled: Bool? + public let accountEmail: String? + public let updatedAt: Date + + public init( + creditsUsed: Double? = nil, + creditsTotal: Double? = nil, + creditsRemaining: Double? = nil, + weeklyUsed: Double? = nil, + weeklyLimit: Double? = nil, + weeklyResetsAt: Date? = nil, + billingPeriodEnd: Date? = nil, + nextQuotaReset: Date? = nil, + tier: String? = nil, + subscriptionStatus: String? = nil, + autoTopUpEnabled: Bool? = nil, + accountEmail: String? = nil, + updatedAt: Date = Date()) + { + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.creditsRemaining = creditsRemaining + self.weeklyUsed = weeklyUsed + self.weeklyLimit = weeklyLimit + self.weeklyResetsAt = weeklyResetsAt + self.billingPeriodEnd = billingPeriodEnd + self.nextQuotaReset = nextQuotaReset + self.tier = tier + self.subscriptionStatus = subscriptionStatus + self.autoTopUpEnabled = autoTopUpEnabled + self.accountEmail = accountEmail + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.makeCreditsWindow() + let secondary = self.makeWeeklyWindow() + + let identity = ProviderIdentitySnapshot( + providerID: .codebuff, + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: self.makeLoginMethod()) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private func makeCreditsWindow() -> RateWindow? { + let total = self.resolvedTotal + guard let total, total > 0 else { + if self.creditsRemaining != nil || self.creditsUsed != nil { + // Degenerate case: no usable quota in the payload. Surface the row as fully + // exhausted so missing quota data is visibly surfaced (matches Kilo's behaviour + // for zero/unknown totals) rather than rendering a misleading healthy bar. + return RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: self.nextQuotaReset, + resetDescription: nil) + } + return nil + } + let used = self.resolvedUsed + let percent = min(100, max(0, (used / total) * 100)) + // Note: do not stuff the credit balance ("X/Y credits") into `resetDescription` — + // generic renderers (UsageFormatter.resetLine) prepend "Resets " when `resetsAt` + // is absent, which would surface misleading text like "Resets 250/1,000 credits". + // The credits detail is shown via the dedicated Codebuff account panel instead. + return RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: self.nextQuotaReset, + resetDescription: nil) + } + + private func makeWeeklyWindow() -> RateWindow? { + guard let limit = self.weeklyLimit, limit > 0 else { return nil } + let used = max(0, self.weeklyUsed ?? 0) + let percent = min(100, max(0, (used / limit) * 100)) + // Same reasoning as above: avoid encoding non-reset detail in `resetDescription`. + return RateWindow( + usedPercent: percent, + windowMinutes: 7 * 24 * 60, + resetsAt: self.weeklyResetsAt, + resetDescription: nil) + } + + private var resolvedTotal: Double? { + if let creditsTotal { return max(0, creditsTotal) } + if let creditsUsed, let creditsRemaining { + return max(0, creditsUsed + creditsRemaining) + } + return nil + } + + private var resolvedUsed: Double { + if let creditsUsed { + return max(0, creditsUsed) + } + if let total = self.resolvedTotal, let creditsRemaining { + return max(0, total - creditsRemaining) + } + return 0 + } + + private func makeLoginMethod() -> String? { + var parts: [String] = [] + if let tier = self.tier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty { + parts.append(tier.capitalized) + } + if let remaining = self.creditsRemaining { + parts.append("\(Self.compactNumber(remaining)) remaining") + } + if self.autoTopUpEnabled == true { + parts.append("auto top-up") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + static func compactNumber(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = value >= 1000 ? 0 : 1 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index c8cb60795..25dcae7ac 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -42,9 +42,17 @@ public enum CodexActiveSourceResolver { liveSystemAccount: ObservedSystemCodexAccount?) -> Bool { guard let liveSystemAccount else { return false } + if let storedFingerprint = storedAccount.authFingerprint, + let liveFingerprint = liveSystemAccount.authFingerprint, + storedFingerprint == liveFingerprint + { + return true + } return CodexIdentityMatcher.matches( snapshot.runtimeIdentity(for: storedAccount), - snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: snapshot.runtimeEmail(for: storedAccount), + snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) } } @@ -153,9 +161,20 @@ public struct DefaultCodexAccountReconciler { nil } let matchingStoredAccountForLiveSystemAccount = liveSystemAccount.flatMap { liveAccount in - accounts.accounts.first { account in + if let liveFingerprint = liveAccount.authFingerprint, + let exactFingerprintMatch = accounts.accounts.first(where: { + $0.authFingerprint == liveFingerprint + }) + { + return exactFingerprintMatch + } + return accounts.accounts.first { account in guard let runtimeAccount = runtimeAccounts[account.id] else { return false } - return CodexIdentityMatcher.matches(runtimeAccount.identity, self.runtimeIdentity(for: liveAccount)) + return CodexIdentityMatcher.matches( + runtimeAccount.identity, + lhsEmail: runtimeAccount.email, + self.runtimeIdentity(for: liveAccount), + rhsEmail: liveAccount.email) } } @@ -192,6 +211,7 @@ public struct DefaultCodexAccountReconciler { email: normalizedEmail, workspaceLabel: account.workspaceLabel, workspaceAccountID: account.workspaceAccountID, + authFingerprint: account.authFingerprint, codexHomePath: account.codexHomePath, observedAt: account.observedAt, identity: self.runtimeIdentity(for: account)) @@ -234,6 +254,22 @@ public enum CodexIdentityMatcher { } } + public static func matches( + _ lhs: CodexIdentity, + lhsEmail: String?, + _ rhs: CodexIdentity, + rhsEmail: String?) -> Bool + { + guard self.matches(lhs, rhs) else { return false } + guard case .providerAccount = lhs, case .providerAccount = rhs else { return true } + guard let normalizedLeftEmail = CodexIdentityResolver.normalizeEmail(lhsEmail), + let normalizedRightEmail = CodexIdentityResolver.normalizeEmail(rhsEmail) + else { + return true + } + return normalizedLeftEmail == normalizedRightEmail + } + public static func normalized(_ identity: CodexIdentity, fallbackEmail: String) -> CodexIdentity { switch identity { case .providerAccount: @@ -272,6 +308,7 @@ private struct AccountIdentity: Equatable { let createdAt: TimeInterval let updatedAt: TimeInterval let lastAuthenticatedAt: TimeInterval? + let authFingerprint: String? init(_ account: ManagedCodexAccount) { self.id = account.id @@ -283,5 +320,6 @@ private struct AccountIdentity: Equatable { self.createdAt = account.createdAt self.updatedAt = account.updatedAt self.lastAuthenticatedAt = account.lastAuthenticatedAt + self.authFingerprint = account.authFingerprint } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLILaunchGate.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLILaunchGate.swift new file mode 100644 index 000000000..78ffa8a7b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLILaunchGate.swift @@ -0,0 +1,70 @@ +import Foundation + +final class CodexCLILaunchGate: @unchecked Sendable { + static let shared = CodexCLILaunchGate() + static let cooldown: TimeInterval = 30 * 60 + + private struct Entry { + let message: String + let expiresAt: Date + } + + private let lock = NSLock() + private var entries: [String: Entry] = [:] + + func backgroundSkipMessage( + binary: String, + now: Date = Date(), + interaction: ProviderInteraction = ProviderInteractionContext.current) -> String? + { + guard interaction == .background else { return nil } + + self.lock.lock() + defer { self.lock.unlock() } + + guard let entry = self.entries[binary] else { return nil } + guard entry.expiresAt > now else { + self.entries.removeValue(forKey: binary) + return nil + } + return entry.message + } + + @discardableResult + func recordLaunchFailure(binary: String, message: String, now: Date = Date()) -> String? { + guard Self.shouldThrottleLaunchFailure(message) else { return nil } + let throttled = Self.throttledMessage(binary: binary, originalMessage: message) + let entry = Entry( + message: throttled, + expiresAt: now.addingTimeInterval(Self.cooldown)) + + self.lock.lock() + self.entries[binary] = entry + self.lock.unlock() + + return throttled + } + + func resetForTesting() { + self.lock.lock() + self.entries.removeAll() + self.lock.unlock() + } + + static func shouldThrottleLaunchFailure(_ message: String) -> Bool { + let lower = message.lowercased() + if lower.contains("openpty") || + lower.contains("write to pty") || + lower.contains("app shutdown") + { + return false + } + return true + } + + private static func throttledMessage(binary: String, originalMessage: String) -> String { + "Codex CLI launch failed; background refresh is paused for 30 minutes. " + + "Reinstall or unblock `\(binary)` in macOS security settings, then refresh manually. " + + "Last error: \(originalMessage)" + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift index 2238fc0b2..a702fc1cf 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift @@ -32,6 +32,17 @@ actor CodexCLISession { private var ptyRows: UInt16 = 0 private var ptyCols: UInt16 = 0 private var sessionEnvironment: [String: String]? + private var sessionArguments: [String] = [] + private var sessionWorkingDirectory: URL? + + struct CaptureOptions { + let timeout: TimeInterval + let rows: UInt16 + let cols: UInt16 + let environment: [String: String] + let extraArgs: [String] + let workingDirectory: URL? + } private struct RollingBuffer { private let maxNeedle: Int @@ -84,12 +95,9 @@ actor CodexCLISession { // swiftlint:disable cyclomatic_complexity func captureStatus( binary: String, - timeout: TimeInterval, - rows: UInt16, - cols: UInt16, - environment: [String: String]) async throws -> String + options: CaptureOptions) async throws -> String { - try self.ensureStarted(binary: binary, rows: rows, cols: cols, environment: environment) + try self.ensureStarted(binary: binary, options: options) if let startedAt { let sinceStart = Date().timeIntervalSince(startedAt) if sinceStart < 0.4 { @@ -117,7 +125,7 @@ actor CodexCLISession { var updateScanBuffer = RollingBuffer(maxNeedle: updateMaxNeedle) var buffer = Data() - let deadline = Date().addingTimeInterval(timeout) + let deadline = Date().addingTimeInterval(options.timeout) var nextCursorCheckAt = Date(timeIntervalSince1970: 0) var skippedCodexUpdate = false @@ -252,16 +260,16 @@ actor CodexCLISession { private func ensureStarted( binary: String, - rows: UInt16, - cols: UInt16, - environment: [String: String]) throws + options: CaptureOptions) throws { if let proc = self.process, proc.isRunning, self.binaryPath == binary, - self.ptyRows == rows, - self.ptyCols == cols, - self.sessionEnvironment == environment + self.ptyRows == options.rows, + self.ptyCols == options.cols, + self.sessionEnvironment == options.environment, + self.sessionArguments == options.extraArgs, + self.sessionWorkingDirectory == options.workingDirectory { return } @@ -269,7 +277,7 @@ actor CodexCLISession { var primaryFD: Int32 = -1 var secondaryFD: Int32 = -1 - var win = winsize(ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0) + var win = winsize(ws_row: options.rows, ws_col: options.cols, ws_xpixel: 0, ws_ypixel: 0) guard openpty(&primaryFD, &secondaryFD, nil, nil, &win) == 0 else { throw SessionError.launchFailed("openpty failed") } @@ -281,14 +289,15 @@ actor CodexCLISession { let proc = Process() let resolvedURL = URL(fileURLWithPath: binary) proc.executableURL = resolvedURL - proc.arguments = ["-s", "read-only", "-a", "untrusted"] + proc.arguments = options.extraArgs proc.standardInput = secondaryHandle proc.standardOutput = secondaryHandle proc.standardError = secondaryHandle + proc.currentDirectoryURL = options.workingDirectory let env = TTYCommandRunner.enrichedEnvironment( - baseEnv: environment, - home: environment["HOME"] ?? NSHomeDirectory()) + baseEnv: options.environment, + home: options.environment["HOME"] ?? NSHomeDirectory()) proc.environment = env do { @@ -324,9 +333,11 @@ actor CodexCLISession { self.processGroup = processGroup self.binaryPath = binary self.startedAt = Date() - self.ptyRows = rows - self.ptyCols = cols - self.sessionEnvironment = environment + self.ptyRows = options.rows + self.ptyCols = options.cols + self.sessionEnvironment = options.environment + self.sessionArguments = options.extraArgs + self.sessionWorkingDirectory = options.workingDirectory } private func cleanup() { @@ -366,6 +377,8 @@ actor CodexCLISession { self.ptyRows = 0 self.ptyCols = 0 self.sessionEnvironment = nil + self.sessionArguments = [] + self.sessionWorkingDirectory = nil } private func readChunk() -> Data { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 7cca42ff8..71455d981 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -209,12 +209,10 @@ public enum CodexOAuthUsageFetcher { } do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw CodexOAuthFetchError.invalidResponse - } + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data - switch http.statusCode { + switch response.statusCode { case 200...299: do { return try JSONDecoder().decode(CodexUsageResponse.self, from: data) @@ -225,7 +223,7 @@ public enum CodexOAuthUsageFetcher { throw CodexOAuthFetchError.unauthorized default: let body = String(data: data, encoding: .utf8) - throw CodexOAuthFetchError.serverError(http.statusCode, body) + throw CodexOAuthFetchError.serverError(response.statusCode, body) } } catch let error as CodexOAuthFetchError { throw error diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift index aa49311fa..c39a3c3b1 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift @@ -49,25 +49,10 @@ public enum CodexTokenRefresher { request.httpBody = try JSONSerialization.data(withJSONObject: body) do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw RefreshError.invalidResponse("No HTTP response") - } - - if http.statusCode == 401 { - if let errorCode = Self.extractErrorCode(from: data) { - switch errorCode.lowercased() { - case "refresh_token_expired": throw RefreshError.expired - case "refresh_token_reused": throw RefreshError.reused - case "refresh_token_invalidated": throw RefreshError.revoked - default: throw RefreshError.expired - } - } - throw RefreshError.expired - } - - guard http.statusCode == 200 else { - throw RefreshError.invalidResponse("Status \(http.statusCode)") + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + throw Self.refreshFailureError(statusCode: response.statusCode, data: data) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -97,4 +82,33 @@ public enum CodexTokenRefresher { if let error = json["error"] as? String { return error } return json["code"] as? String } + + private static func refreshFailureError(statusCode: Int, data: Data) -> RefreshError { + if let errorCode = extractErrorCode(from: data) { + switch errorCode.lowercased() { + case "refresh_token_expired": + return .expired + case "refresh_token_reused": + return .reused + case "invalid_grant", "refresh_token_invalidated": + return .revoked + default: + break + } + } + + if statusCode == 401 { + return .expired + } + + return .invalidResponse("Status \(statusCode)") + } } + +#if DEBUG +extension CodexTokenRefresher { + static func _refreshFailureErrorForTesting(statusCode: Int, data: Data) -> RefreshError { + self.refreshFailureError(statusCode: statusCode, data: data) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift index 6887b4ce9..4aea36710 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift @@ -38,39 +38,51 @@ public enum CodexOpenAIWorkspaceResolver { public static func resolve( credentials: CodexOAuthCredentials, - session: URLSession = .shared) async throws -> CodexOpenAIWorkspaceIdentity? + session transport: any ProviderHTTPTransport = ProviderHTTPClient + .shared) async throws -> CodexOpenAIWorkspaceIdentity? { guard let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) else { return nil } + let identities = try await self.listWorkspaces(credentials: credentials, session: transport) + if let identity = identities.first(where: { $0.workspaceAccountID == workspaceAccountID }) { + return identity + } + + return CodexOpenAIWorkspaceIdentity( + workspaceAccountID: workspaceAccountID, + workspaceLabel: nil) + } + + public static func listWorkspaces( + credentials: CodexOAuthCredentials, + session transport: any ProviderHTTPTransport = ProviderHTTPClient + .shared) async throws -> [CodexOpenAIWorkspaceIdentity] + { var request = URLRequest(url: self.accountsURL) request.httpMethod = "GET" request.timeoutInterval = 20 request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + if let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) { + request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + } - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, - (200..<300).contains(httpResponse.statusCode) + let response = try await transport.response(for: request) + guard (200..<300).contains(response.statusCode) else { throw CodexOpenAIWorkspaceResolverError.invalidResponse } - let decoded = try JSONDecoder().decode(AccountsResponse.self, from: data) - if let account = decoded.items.first(where: { - Self.normalizeWorkspaceAccountID($0.id) == workspaceAccountID - }) { + let decoded = try JSONDecoder().decode(AccountsResponse.self, from: response.data) + return decoded.items.compactMap { account in + guard let workspaceAccountID = self.normalizeWorkspaceAccountID(account.id) else { return nil } return CodexOpenAIWorkspaceIdentity( workspaceAccountID: workspaceAccountID, workspaceLabel: self.resolveWorkspaceLabel(from: account)) } - - return CodexOpenAIWorkspaceIdentity( - workspaceAccountID: workspaceAccountID, - workspaceLabel: nil) } public static func normalizeWorkspaceAccountID(_ value: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift b/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift index c08d578fa..af546a380 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexPlanFormatting.swift @@ -2,10 +2,11 @@ import Foundation public enum CodexPlanFormatting { private static let exactDisplayNames: [String: String] = [ - "prolite": "Pro Lite", - "pro_lite": "Pro Lite", - "pro-lite": "Pro Lite", - "pro lite": "Pro Lite", + "pro": "Pro 20x", + "prolite": "Pro 5x", + "pro_lite": "Pro 5x", + "pro-lite": "Pro 5x", + "pro lite": "Pro 5x", ] private static let uppercaseWords: Set = [ @@ -27,6 +28,10 @@ public enum CodexPlanFormatting { let candidate = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) guard !candidate.isEmpty else { return raw } + if let exact = Self.exactDisplayNames[candidate.lowercased()] { + return exact + } + let components = candidate .split(whereSeparator: { $0 == "_" || $0 == "-" || $0.isWhitespace }) .map(String.init) @@ -40,9 +45,6 @@ public enum CodexPlanFormatting { private static func wordDisplayName(_ raw: String) -> String { let lower = raw.lowercased() - if let exact = Self.exactDisplayNames[lower] { - return exact - } if Self.uppercaseWords.contains(lower) { return lower.uppercased() } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 202baa600..cb39b9c95 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -24,6 +24,7 @@ public enum CodexProviderDescriptor { browserCookieOrder: ProviderBrowserCookieDefaults.codexCookieImportOrder ?? ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://chatgpt.com/codex/settings/usage", + changelogURL: "https://github.com/openai/codex/releases", statusPageURL: "https://status.openai.com/"), branding: ProviderBranding( iconStyle: .codex, @@ -113,11 +114,9 @@ struct CodexCLIUsageStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let keepAlive = context.settings?.debugKeepCLISessionsAlive ?? false - let usage = try await context.fetcher.loadLatestUsage(keepCLISessionsAlive: keepAlive) - let credits = await context.includeCredits - ? (try? context.fetcher.loadLatestCredits(keepCLISessionsAlive: keepAlive)) - : nil + let snapshot = try await context.fetcher.loadLatestCLIAccountSnapshot() + guard let usage = snapshot.usage else { throw UsageError.noRateLimitsFound } + let credits = context.includeCredits ? snapshot.credits : nil return self.makeResult( usage: usage, credits: credits, @@ -159,7 +158,33 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { guard context.sourceMode == .auto else { return false } - return true + + // Auto mode may launch the CLI as the next strategy. Keep that fallback + // limited to OAuth states the CLI can actually repair, otherwise + // transient API or decode failures can spawn `codex app-server` + // repeatedly instead of surfacing the original OAuth failure. + if let fetchError = error as? CodexOAuthFetchError { + switch fetchError { + case .unauthorized: + return true + case .invalidResponse, .serverError, .networkError: + return false + } + } + if let credentialsError = error as? CodexOAuthCredentialsError { + switch credentialsError { + case .notFound, .missingTokens: + return true + case .decodeFailed: + return false + } + } + switch error as? CodexTokenRefresher.RefreshError { + case .expired, .revoked, .reused: + return true + case .networkError, .invalidResponse, .none: + return false + } } private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? { @@ -179,13 +204,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { credentials: credentials, updatedAt: updatedAt) - if sourceMode == .auto, - usageResponse.rateLimit?.hasWindowDecodeFailure == true, - reconciled?.session == nil - { - throw UsageError.noRateLimitsFound - } - if let reconciled { return CodexOAuthFetchStrategy().makeResult( usage: reconciled.toUsageSnapshot(), @@ -197,10 +215,9 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { throw UsageError.noRateLimitsFound } - if sourceMode == .auto { - throw UsageError.noRateLimitsFound - } - + // Credits can still be useful when the OAuth API omits or partially + // fails to decode rate-limit windows. Returning the partial OAuth result + // prevents auto mode from escalating a usable response into CLI fallback. return CodexOAuthFetchStrategy().makeResult( usage: UsageSnapshot( primary: nil, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index be7fdc8f2..6da629082 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -33,6 +33,7 @@ public struct CodexStatusSnapshot: Sendable { public enum CodexStatusProbeError: LocalizedError, Sendable { case codexNotInstalled + case launchBlocked(String) case parseFailed(String) case timedOut case updateRequired(String) @@ -41,6 +42,8 @@ public enum CodexStatusProbeError: LocalizedError, Sendable { switch self { case .codexNotInstalled: "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart." + case let .launchBlocked(message): + message case .parseFailed: "Could not parse Codex status; will retry shortly." case .timedOut: @@ -82,6 +85,9 @@ public struct CodexStatusProbe { guard FileManager.default.isExecutableFile(atPath: resolved) || TTYCommandRunner.which(resolved) != nil else { throw CodexStatusProbeError.codexNotInstalled } + if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolved) { + throw CodexStatusProbeError.launchBlocked(message) + } do { return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout) } catch let error as CodexStatusProbeError { @@ -198,35 +204,53 @@ public struct CodexStatusProbe { cols: UInt16, timeout: TimeInterval) async throws -> CodexStatusSnapshot { + let stateHome = try CodexStatusProbeIsolation.supportDirectory(environment: self.environment) + let extraArgs = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome) + let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: self.environment) let text: String if self.keepCLISessionsAlive { do { text = try await CodexCLISession.shared.captureStatus( binary: binary, - timeout: timeout, - rows: rows, - cols: cols, - environment: self.environment) + options: .init( + timeout: timeout, + rows: rows, + cols: cols, + environment: self.environment, + extraArgs: extraArgs, + workingDirectory: workingDirectory)) } catch CodexCLISession.SessionError.processExited { throw CodexStatusProbeError.timedOut } catch CodexCLISession.SessionError.timedOut { throw CodexStatusProbeError.timedOut - } catch CodexCLISession.SessionError.launchFailed(_) { + } catch let CodexCLISession.SessionError.launchFailed(message) { + if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) { + throw CodexStatusProbeError.launchBlocked(throttled) + } throw CodexStatusProbeError.codexNotInstalled } } else { let runner = TTYCommandRunner() let script = "/status" - let result = try runner.run( - binary: binary, - send: script, - options: .init( - rows: rows, - cols: cols, - timeout: timeout, - extraArgs: ["-s", "read-only", "-a", "untrusted"], - baseEnvironment: self.environment, - forceCodexStatusMode: true)) + let result: TTYCommandRunner.Result + do { + result = try runner.run( + binary: binary, + send: script, + options: .init( + rows: rows, + cols: cols, + timeout: timeout, + workingDirectory: workingDirectory, + extraArgs: extraArgs, + baseEnvironment: self.environment, + forceCodexStatusMode: true)) + } catch let TTYCommandRunner.Error.launchFailed(message) { + if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) { + throw CodexStatusProbeError.launchBlocked(throttled) + } + throw CodexStatusProbeError.codexNotInstalled + } text = result.text } return try Self.parse(text: text) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbeIsolation.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbeIsolation.swift new file mode 100644 index 000000000..2401122e4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbeIsolation.swift @@ -0,0 +1,46 @@ +import Foundation + +enum CodexStatusProbeIsolation { + static func supportDirectory(environment: [String: String]) throws -> URL { + let baseURL: URL = if let tmp = environment["TMPDIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !tmp.isEmpty + { + URL(fileURLWithPath: tmp, isDirectory: true) + } else { + FileManager.default.temporaryDirectory + } + + let directory = baseURL + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("CodexStatusProbe", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + static func workingDirectory(environment: [String: String]) -> URL? { + let home = environment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let home, !home.isEmpty else { return nil } + return URL(fileURLWithPath: home, isDirectory: true) + } + + static func codexArguments(stateHome: URL) -> [String] { + [ + "-s", + "read-only", + "-a", + "untrusted", + "-c", + "history.persistence=\"none\"", + "-c", + "experimental_thread_store={type=\"in_memory\",id=\"codexbar-status\"}", + "-c", + "sqlite_home=\"\(self.tomlEscaped(stateHome.path))\"", + ] + } + + private static func tomlEscaped(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexSystemAccountObserver.swift b/Sources/CodexBarCore/Providers/Codex/CodexSystemAccountObserver.swift index 15489b2c6..5b6620973 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexSystemAccountObserver.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexSystemAccountObserver.swift @@ -4,6 +4,7 @@ public struct ObservedSystemCodexAccount: Equatable, Sendable { public let email: String public let workspaceLabel: String? public let workspaceAccountID: String? + public let authFingerprint: String? public let codexHomePath: String public let observedAt: Date public let identity: CodexIdentity @@ -12,6 +13,7 @@ public struct ObservedSystemCodexAccount: Equatable, Sendable { email: String, workspaceLabel: String? = nil, workspaceAccountID: String? = nil, + authFingerprint: String? = nil, codexHomePath: String, observedAt: Date, identity: CodexIdentity = .unresolved) @@ -19,6 +21,7 @@ public struct ObservedSystemCodexAccount: Equatable, Sendable { self.email = email self.workspaceLabel = workspaceLabel self.workspaceAccountID = workspaceAccountID + self.authFingerprint = CodexAuthFingerprint.normalize(authFingerprint) self.codexHomePath = codexHomePath self.observedAt = observedAt self.identity = identity @@ -58,6 +61,7 @@ public struct DefaultCodexSystemAccountObserver: CodexSystemAccountObserving { email: rawEmail.lowercased(), workspaceLabel: self.workspaceCache.workspaceLabel(for: providerAccountID), workspaceAccountID: providerAccountID, + authFingerprint: CodexAuthFingerprint.fingerprint(homePath: homeURL.path), codexHomePath: homeURL.path, observedAt: Date(), identity: account.identity) diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieHeader.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieHeader.swift new file mode 100644 index 000000000..f5835c80b --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieHeader.swift @@ -0,0 +1,85 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A resolved CommandCode session cookie ready to be sent on `Cookie:` headers. +/// +/// CommandCode's API (api.commandcode.ai) authenticates with the better-auth session +/// cookie set by commandcode.ai. better-auth emits either `better-auth.session_token` +/// or `__Secure-better-auth.session_token` depending on whether `useSecureCookies` is +/// enabled (the `__Secure-` variant is required by browsers for HTTPS production). +public struct CommandCodeCookieOverride: Sendable, Equatable { + public let name: String + public let token: String + + public init(name: String, token: String) { + self.name = name + self.token = token + } + + /// `Cookie: name=value` header value. + public var headerValue: String { + "\(self.name)=\(self.token)" + } +} + +public enum CommandCodeCookieHeader { + /// Cookie names used by better-auth in production (HTTPS) and dev (HTTP). + /// The `__Secure-` variant is the standard production deployment. + public static let supportedSessionCookieNames = [ + "__Host-better-auth.session_token", + "__Secure-better-auth.session_token", + "better-auth.session_token", + ] + + /// Extract a session cookie from a list of `HTTPCookie` records. + public static func sessionCookie(from cookies: [HTTPCookie]) -> CommandCodeCookieOverride? { + let pairs = cookies.map { (name: $0.name, value: $0.value) } + return self.extractSessionCookie(from: pairs) + } + + /// Parse a raw `Cookie:` header (or bare token) and extract the session value. + public static func override(from raw: String?) -> CommandCodeCookieOverride? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + // Bare token — assume the production cookie name. + if !raw.contains("="), !raw.contains(";") { + return CommandCodeCookieOverride( + name: "__Secure-better-auth.session_token", + token: raw) + } + + return self.extractSessionCookie(fromHeader: raw) + } + + private static func extractSessionCookie(fromHeader header: String) -> CommandCodeCookieOverride? { + var pairs: [(name: String, value: String)] = [] + for chunk in header.split(separator: ";") { + let trimmed = chunk.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. CommandCodeCookieOverride? { + var byLowerName: [String: (name: String, value: String)] = [:] + for pair in pairs { + byLowerName[pair.name.lowercased()] = pair + } + for expected in self.supportedSessionCookieNames { + if let match = byLowerName[expected.lowercased()] { + return CommandCodeCookieOverride(name: match.name, token: match.value) + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieImporter.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieImporter.swift new file mode 100644 index 000000000..3db2c5293 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeCookieImporter.swift @@ -0,0 +1,230 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +/// Imports CommandCode session cookies from installed browsers (Chrome by default). +public enum CommandCodeCookieImporter { + private static let importSessionCacheTTL: TimeInterval = 5 + private static let importSessionCache = ImportSessionCache(ttl: importSessionCacheTTL) + private static let log = CodexBarLog.logger(LogCategories.commandcodeCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["commandcode.ai", "www.commandcode.ai"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.commandcode]?.browserCookieOrder ?? Browser.defaultImportOrder + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var sessionCookie: CommandCodeCookieOverride? { + CommandCodeCookieHeader.sessionCookie(from: self.cookies) + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let cached = self.cachedImportSessions() { + return cached + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw CommandCodeCookieImportError.noCookies + } + self.storeImportSessions(sessions) + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + let session = SessionInfo(cookies: httpCookies, sourceLabel: label) + if let sessionCookie = session.sessionCookie { + log("Found \(sessionCookie.name) cookie in \(label)") + } else { + let names = httpCookies.map(\.name).joined(separator: ", ") + log("No known session name in \(label); sending all domain cookies (\(names))") + } + sessions.append(session) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { throw CommandCodeCookieImportError.noCookies } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + let session = try self.importSession(browserDetection: browserDetection, logger: logger) + return !session.cookies.isEmpty + } catch { + return false + } + } + + static func invalidateImportSessionCache() { + self.importSessionCache.invalidate() + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[commandcode-cookie] \(message)") + self.log.debug(message) + } + + private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? { + self.importSessionCache.load(now: now) + } + + private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) { + self.importSessionCache.store(sessions, now: now) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (sessions: [SessionInfo], expiresAt: Date)? + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + func load(now: Date) -> [SessionInfo]? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entry else { return nil } + guard entry.expiresAt > now else { + self.entry = nil + return nil + } + return entry.sessions + } + + func store(_ sessions: [SessionInfo], now: Date) { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl)) + } + + func invalidate() { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = nil + } + } +} + +public enum CommandCodeCookieImportError: LocalizedError { + case noCookies + + public var errorDescription: String? { + switch self { + case .noCookies: + "No Command Code session cookies found in browsers. Sign in to commandcode.ai." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodePlanCatalog.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodePlanCatalog.swift new file mode 100644 index 000000000..16c7bcea2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodePlanCatalog.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Static catalog of CommandCode subscription plans → monthly credit allowance (in USD). +/// +/// The `/internal/billing/credits` endpoint exposes the *remaining* `monthlyCredits`, +/// not the plan total. The plan total is published on the public pricing page +/// (https://commandcode.ai/pricing) and is keyed by `planId` returned from +/// `/internal/billing/subscriptions`. +public enum CommandCodePlanCatalog { + public struct Plan: Sendable, Equatable { + public let id: String + public let displayName: String + /// Monthly credit allowance in USD. + public let monthlyCreditsUSD: Double + + public init(id: String, displayName: String, monthlyCreditsUSD: Double) { + self.id = id + self.displayName = displayName + self.monthlyCreditsUSD = monthlyCreditsUSD + } + } + + public static let plans: [Plan] = [ + Plan(id: "individual-go", displayName: "Go", monthlyCreditsUSD: 10), + Plan(id: "individual-pro", displayName: "Pro", monthlyCreditsUSD: 30), + Plan(id: "individual-max", displayName: "Max", monthlyCreditsUSD: 150), + Plan(id: "individual-ultra", displayName: "Ultra", monthlyCreditsUSD: 300), + ] + + public static func plan(forID planID: String) -> Plan? { + let normalized = planID.lowercased() + return self.plans.first(where: { $0.id == normalized }) + } +} diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift new file mode 100644 index 000000000..b4ee6735b --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeProviderDescriptor.swift @@ -0,0 +1,96 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CommandCodeProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .commandcode, + metadata: ProviderMetadata( + id: .commandcode, + displayName: "Command Code", + sessionLabel: "Monthly credits", + weeklyLabel: "Monthly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Monthly USD credits from Command Code billing.", + toggleTitle: "Show Command Code usage", + cliName: "commandcode", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://commandcode.ai/studio", + subscriptionDashboardURL: "https://commandcode.ai/sixhobbits/settings/billing", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .commandcode, + iconResourceName: "ProviderIcon-commandcode", + color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Command Code cost summary is not yet supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CommandCodeWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "commandcode", + aliases: ["command-code"], + versionDetector: nil)) + } +} + +struct CommandCodeWebFetchStrategy: ProviderFetchStrategy { + let id: String = "commandcode.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.commandcode?.cookieSource != .off else { return false } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + #if os(macOS) + let cookieHeader: String + let sourceLabel: String + if let manual = Self.manualCookieHeader(from: context) { + cookieHeader = manual + sourceLabel = "manual" + } else { + let session: CommandCodeCookieImporter.SessionInfo + do { + session = try CommandCodeCookieImporter.importSession() + } catch { + throw CommandCodeUsageError.missingCredentials + } + guard !session.cookies.isEmpty else { + throw CommandCodeUsageError.missingCredentials + } + cookieHeader = session.cookieHeader + sourceLabel = session.sourceLabel + } + let snapshot = try await CommandCodeUsageFetcher.fetchUsage(cookieHeader: cookieHeader) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: sourceLabel) + #else + throw CommandCodeUsageError.missingCredentials + #endif + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.commandcode?.cookieSource == .manual else { return nil } + return CookieHeaderNormalizer.normalize(context.settings?.commandcode?.manualCookieHeader) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageError.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageError.swift new file mode 100644 index 000000000..9e13b8914 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageError.swift @@ -0,0 +1,34 @@ +import Foundation + +public enum CommandCodeUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case invalidCredentials + case networkError(String) + case apiError(Int) + case parseFailed(String) + case unknownPlan(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Command Code session cookie not found. Sign in to commandcode.ai in Chrome." + case .invalidCredentials: + "Command Code session is invalid or expired. Sign in to commandcode.ai again." + case let .networkError(message): + "Command Code network error: \(message)" + case let .apiError(status): + "Command Code API returned status \(status)." + case let .parseFailed(message): + "Could not parse Command Code response: \(message)" + case let .unknownPlan(planID): + "Unknown Command Code plan: \(planID). Add it to CommandCodePlanCatalog." + } + } + + public var isAuthRelated: Bool { + switch self { + case .missingCredentials, .invalidCredentials: true + default: false + } + } +} diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift new file mode 100644 index 000000000..4c0b28244 --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageFetcher.swift @@ -0,0 +1,181 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Fetches live billing data from `api.commandcode.ai` using a better-auth session +/// cookie scraped from the user's browser. +public enum CommandCodeUsageFetcher { + private static let log = CodexBarLog.logger(LogCategories.commandcodeUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + private static let apiBase = URL(string: "https://api.commandcode.ai")! + private static let creditsPath = "/internal/billing/credits" + private static let subscriptionsPath = "/internal/billing/subscriptions" + private static let webOrigin = "https://commandcode.ai" + private static let userAgent = + "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" + + public static func fetchUsage( + cookieHeader: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> CommandCodeUsageSnapshot + { + async let creditsResult = self.fetchCredits(cookieHeader: cookieHeader, transport: transport) + async let subscriptionResult = self.fetchSubscription(cookieHeader: cookieHeader, transport: transport) + + let credits = try await creditsResult + let subscription = try await subscriptionResult + + let plan: CommandCodePlanCatalog.Plan? = subscription.flatMap { sub in + CommandCodePlanCatalog.plan(forID: sub.planID) + } + + // If we got an active subscription with an unrecognised plan ID, surface that + // explicitly rather than silently dropping the totals row. + if let sub = subscription, sub.status.lowercased() == "active", plan == nil { + Self.log.error("Unknown CommandCode planId: \(sub.planID)") + throw CommandCodeUsageError.unknownPlan(sub.planID) + } + + return CommandCodeUsageSnapshot( + monthlyCreditsRemaining: credits.monthlyCredits, + purchasedCredits: credits.purchasedCredits, + premiumMonthlyCredits: credits.premiumMonthlyCredits, + opensourceMonthlyCredits: credits.opensourceMonthlyCredits, + plan: plan, + billingPeriodEnd: subscription?.currentPeriodEnd, + subscriptionStatus: subscription?.status, + updatedAt: now) + } + + // MARK: - Endpoints + + struct CreditsPayload { + let monthlyCredits: Double + let purchasedCredits: Double + let premiumMonthlyCredits: Double + let opensourceMonthlyCredits: Double + } + + struct SubscriptionPayload { + let planID: String + let status: String + let currentPeriodEnd: Date? + } + + private static func fetchCredits( + cookieHeader: String, + transport: any ProviderHTTPTransport) async throws -> CreditsPayload + { + let url = self.apiBase.appendingPathComponent(self.creditsPath) + let data = try await self.send(url: url, cookieHeader: cookieHeader, transport: transport) + return try self.parseCredits(data: data) + } + + private static func fetchSubscription( + cookieHeader: String, + transport: any ProviderHTTPTransport) async throws -> SubscriptionPayload? + { + let url = self.apiBase.appendingPathComponent(self.subscriptionsPath) + let data = try await self.send(url: url, cookieHeader: cookieHeader, transport: transport) + return try self.parseSubscription(data: data) + } + + private static func send( + url: URL, + cookieHeader: String, + transport: any ProviderHTTPTransport) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(self.webOrigin, forHTTPHeaderField: "Origin") + request.setValue("\(self.webOrigin)/", forHTTPHeaderField: "Referer") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw CommandCodeUsageError.networkError(error.localizedDescription) + } + if response.statusCode == 401 || response.statusCode == 403 { + throw CommandCodeUsageError.invalidCredentials + } + guard (200..<300).contains(response.statusCode) else { + let body = String(data: response.data, encoding: .utf8) ?? "" + Self.log.error("CommandCode \(url.path) → \(response.statusCode): \(body)") + throw CommandCodeUsageError.apiError(response.statusCode) + } + return response.data + } + + // MARK: - Parsing + + static func parseCredits(data: Data) throws -> CreditsPayload { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CommandCodeUsageError.parseFailed("Credits: invalid JSON") + } + guard let credits = root["credits"] as? [String: Any] else { + throw CommandCodeUsageError.parseFailed("Credits: missing 'credits' object") + } + guard let monthly = self.double(from: credits["monthlyCredits"]) else { + throw CommandCodeUsageError.parseFailed("Credits: missing monthlyCredits") + } + return CreditsPayload( + monthlyCredits: monthly, + purchasedCredits: self.double(from: credits["purchasedCredits"]) ?? 0, + premiumMonthlyCredits: self.double(from: credits["premiumMonthlyCredits"]) ?? 0, + opensourceMonthlyCredits: self.double(from: credits["opensourceMonthlyCredits"]) ?? 0) + } + + static func parseSubscription(data: Data) throws -> SubscriptionPayload? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CommandCodeUsageError.parseFailed("Subscriptions: invalid JSON") + } + // {"success":true,"data":{...}} when subscribed; data may be missing or null on free tier. + guard root["success"] as? Bool ?? false else { + return nil + } + guard let data = root["data"] as? [String: Any] else { + return nil + } + guard let planID = data["planId"] as? String, !planID.isEmpty else { + throw CommandCodeUsageError.parseFailed("Subscriptions: missing planId") + } + let status = (data["status"] as? String) ?? "unknown" + let periodEnd = self.date(from: data["currentPeriodEnd"]) + return SubscriptionPayload(planID: planID, status: status, currentPeriodEnd: periodEnd) + } + + // MARK: - Value coercion + + private static func double(from value: Any?) -> Double? { + switch value { + case let n as NSNumber: + let d = n.doubleValue + return d.isFinite ? d : nil + case let s as String: + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + default: + return nil + } + } + + private static func date(from value: Any?) -> Date? { + guard let s = value as? String else { return nil } + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: trimmed) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: trimmed) + } +} diff --git a/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift new file mode 100644 index 000000000..b93fe8d9f --- /dev/null +++ b/Sources/CodexBarCore/Providers/CommandCode/CommandCodeUsageSnapshot.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Parsed view of CommandCode `/internal/billing/credits` + `/internal/billing/subscriptions`. +public struct CommandCodeUsageSnapshot: Sendable { + /// USD remaining in the current monthly grant (`credits.monthlyCredits`). + public let monthlyCreditsRemaining: Double + /// USD top-up balance carried over (`credits.purchasedCredits`). + public let purchasedCredits: Double + /// USD remaining in the premium monthly grant (`credits.premiumMonthlyCredits`). + public let premiumMonthlyCredits: Double + /// USD remaining in the open-source monthly grant (`credits.opensourceMonthlyCredits`). + public let opensourceMonthlyCredits: Double + /// Subscription plan, or nil when the user is on the free tier. + public let plan: CommandCodePlanCatalog.Plan? + /// `currentPeriodEnd` from the active subscription. + public let billingPeriodEnd: Date? + /// Subscription status (e.g. `active`, `canceled`). + public let subscriptionStatus: String? + public let updatedAt: Date + + public init( + monthlyCreditsRemaining: Double, + purchasedCredits: Double, + premiumMonthlyCredits: Double, + opensourceMonthlyCredits: Double, + plan: CommandCodePlanCatalog.Plan?, + billingPeriodEnd: Date?, + subscriptionStatus: String?, + updatedAt: Date = Date()) + { + self.monthlyCreditsRemaining = monthlyCreditsRemaining + self.purchasedCredits = purchasedCredits + self.premiumMonthlyCredits = premiumMonthlyCredits + self.opensourceMonthlyCredits = opensourceMonthlyCredits + self.plan = plan + self.billingPeriodEnd = billingPeriodEnd + self.subscriptionStatus = subscriptionStatus + self.updatedAt = updatedAt + } + + /// USD allocation for the active monthly grant (from the catalog). + public var monthlyCreditsTotal: Double? { + self.plan?.monthlyCreditsUSD + } + + /// USD spent in the current monthly grant (total – remaining), clamped to [0, total]. + public var monthlyCreditsUsed: Double? { + guard let total = self.monthlyCreditsTotal else { return nil } + return max(0, min(total, total - self.monthlyCreditsRemaining)) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = self.makePrimaryWindow() + + let identity = ProviderIdentitySnapshot( + providerID: .commandcode, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.makeLoginMethod()) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private func makePrimaryWindow() -> RateWindow? { + guard let total = self.monthlyCreditsTotal, total > 0 else { + // Free / unknown plan with no allowance — surface 100% so the bar renders empty. + if self.monthlyCreditsRemaining > 0 || self.purchasedCredits > 0 { + return RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: self.billingPeriodEnd, + resetDescription: nil) + } + return nil + } + let used = self.monthlyCreditsUsed ?? 0 + let percent = min(100, max(0, (used / total) * 100)) + return RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: self.billingPeriodEnd, + resetDescription: nil) + } + + private func makeLoginMethod() -> String? { + var parts: [String] = [] + if let name = self.plan?.displayName, !name.isEmpty { + parts.append(name) + } + if let total = self.monthlyCreditsTotal { + let used = self.monthlyCreditsUsed ?? 0 + parts.append("\(Self.formatUSD(used)) of \(Self.formatUSD(total))") + } else if self.monthlyCreditsRemaining > 0 { + parts.append("\(Self.formatUSD(self.monthlyCreditsRemaining)) remaining") + } + if self.purchasedCredits > 0 { + parts.append("+ \(Self.formatUSD(self.purchasedCredits)) credits") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + static func formatUSD(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = "USD" + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = value < 100 ? 2 : 0 + formatter.minimumFractionDigits = value < 100 ? 2 : 0 + return formatter.string(from: NSNumber(value: value)) ?? "$\(value)" + } +} diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift index ff778adf8..ed003330b 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift @@ -4,8 +4,11 @@ import FoundationNetworking #endif public struct CopilotDeviceFlow: Sendable { + public static let defaultHost = "github.com" + private let clientID = "Iv1.b507a08c87ecfe98" // VS Code Client ID private let scopes = "read:user" + private let host: String public struct DeviceCodeResponse: Decodable, Sendable { public let deviceCode: String @@ -41,11 +44,48 @@ public struct CopilotDeviceFlow: Sendable { } } - public init() {} + public init(enterpriseHost: String? = nil) { + self.host = Self.normalizedHost(enterpriseHost) + } + + public var deviceCodeURL: URL? { + Self.makeRequestURL(host: self.host, path: "/login/device/code") + } + + public var accessTokenURL: URL? { + Self.makeRequestURL(host: self.host, path: "/login/oauth/access_token") + } + + public static func normalizedHost(_ raw: String?) -> String { + guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return self.defaultHost + } + let componentsValue = host.contains("://") ? host : "https://\(host)" + if let components = URLComponents(string: componentsValue), + let parsedHost = components.host, + !parsedHost.isEmpty + { + host = parsedHost + if let port = components.port { + host += ":\(port)" + } + } else { + if host.hasPrefix("https://") { + host.removeFirst("https://".count) + } else if host.hasPrefix("http://") { + host.removeFirst("http://".count) + } + host = host.split(separator: "/", maxSplits: 1).first.map(String.init) ?? host + } + let normalized = host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased() + return normalized.isEmpty ? Self.defaultHost : normalized + } public func requestDeviceCode() async throws -> DeviceCodeResponse { - let components = URLComponents(string: "https://github.com/login/device/code")! - let request = URLRequest(url: components.url!) + guard let deviceCodeURL = self.deviceCodeURL else { + throw URLError(.badURL) + } + let request = URLRequest(url: deviceCodeURL) var postRequest = request postRequest.httpMethod = "POST" @@ -58,18 +98,20 @@ public struct CopilotDeviceFlow: Sendable { ] postRequest.httpBody = Self.formURLEncodedBody(body) - let (data, response) = try await URLSession.shared.data(for: postRequest) + let response = try await ProviderHTTPClient.shared.response(for: postRequest) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + guard response.statusCode == 200 else { throw URLError(.badServerResponse) } - return try JSONDecoder().decode(DeviceCodeResponse.self, from: data) + return try JSONDecoder().decode(DeviceCodeResponse.self, from: response.data) } public func pollForToken(deviceCode: String, interval: Int) async throws -> String { - let url = URL(string: "https://github.com/login/oauth/access_token")! - var request = URLRequest(url: url) + guard let accessTokenURL = self.accessTokenURL else { + throw URLError(.badURL) + } + var request = URLRequest(url: accessTokenURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") @@ -85,10 +127,10 @@ public struct CopilotDeviceFlow: Sendable { try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) try Task.checkCancellation() - let (data, _) = try await URLSession.shared.data(for: request) + let response = try await ProviderHTTPClient.shared.response(for: request) // Check for error in JSON - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + if let json = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any], let error = json["error"] as? String { if error == "authorization_pending" { @@ -104,7 +146,7 @@ public struct CopilotDeviceFlow: Sendable { throw URLError(.userAuthenticationRequired) // Generic failure } - if let tokenResponse = try? JSONDecoder().decode(AccessTokenResponse.self, from: data) { + if let tokenResponse = try? JSONDecoder().decode(AccessTokenResponse.self, from: response.data) { return tokenResponse.accessToken } } @@ -119,6 +161,10 @@ public struct CopilotDeviceFlow: Sendable { return Data(pairs.utf8) } + static func makeRequestURL(host: String, path: String) -> URL? { + URL(string: "https://\(host)\(path)") + } + private static func formEncode(_ value: String) -> String { var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "+&=") diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 9e2b063cc..3709cabed 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -44,14 +44,16 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .apiToken func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil + Self.resolveToken(context: context) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let token = Self.resolveToken(environment: context.env), !token.isEmpty else { + guard let token = Self.resolveToken(context: context), !token.isEmpty else { throw URLError(.userAuthenticationRequired) } - let fetcher = CopilotUsageFetcher(token: token) + let fetcher = CopilotUsageFetcher( + token: token, + enterpriseHost: context.settings?.copilot?.enterpriseHost) let snap = try await fetcher.fetch() return self.makeResult( usage: snap, @@ -62,7 +64,10 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { false } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.copilotToken(environment: environment) + private static func resolveToken(context: ProviderFetchContext) -> String? { + ProviderTokenResolver.copilotToken(environment: context.env) + ?? ProviderTokenResolver.copilotResolution(environment: [ + "COPILOT_API_TOKEN": context.settings?.copilot?.apiToken ?? "", + ])?.token } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a..8aa9b2214 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -4,14 +4,43 @@ import FoundationNetworking #endif public struct CopilotUsageFetcher: Sendable { + public struct GitHubUserIdentity: Decodable, Equatable, Sendable { + public let id: Int64 + public let login: String + + public init(id: Int64, login: String) { + self.id = id + self.login = login + } + } + private let token: String + private let enterpriseHost: String? - public init(token: String) { + public init(token: String, enterpriseHost: String? = nil) { self.token = token + self.enterpriseHost = enterpriseHost + } + + public static func apiHost(enterpriseHost: String?) -> String { + let host = CopilotDeviceFlow.normalizedHost(enterpriseHost) + if host == CopilotDeviceFlow.defaultHost { + return "api.github.com" + } + if host.hasPrefix("api.") { + return host + } + return "api.\(host)" + } + + public static func usageURL(enterpriseHost: String?) -> URL? { + CopilotDeviceFlow.makeRequestURL( + host: self.apiHost(enterpriseHost: enterpriseHost), + path: "/copilot_internal/user") } public func fetch() async throws -> UsageSnapshot { - guard let url = URL(string: "https://api.github.com/copilot_internal/user") else { + guard let url = Self.usageURL(enterpriseHost: self.enterpriseHost) else { throw URLError(.badURL) } @@ -20,23 +49,19 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization") self.addCommonHeaders(to: &request) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } + let response = try await ProviderHTTPClient.shared.response(for: request) - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if response.statusCode == 401 || response.statusCode == 403 { throw URLError(.userAuthenticationRequired) } - guard httpResponse.statusCode == 200 else { + guard response.statusCode == 200 else { throw URLError(.badServerResponse) } - let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: data) - let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) - let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat) + let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: response.data) + let premium = Self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) + let chat = Self.makeRateWindow(from: usage.quotaSnapshots.chat) let primary: RateWindow? let secondary: RateWindow? @@ -66,6 +91,33 @@ public struct CopilotUsageFetcher: Sendable { identity: identity) } + public static func fetchGitHubUsername(token: String) async throws -> String { + try await self.fetchGitHubIdentity(token: token).login + } + + public static func fetchGitHubIdentity( + token: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + async throws -> GitHubUserIdentity + { + guard let url = URL(string: "https://api.github.com/user") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await transport.response(for: request) + switch response.statusCode { + case 200: + return try JSONDecoder().decode(GitHubUserIdentity.self, from: response.data) + case 401, 403: + throw URLError(.userAuthenticationRequired) + default: + throw URLError(.badServerResponse) + } + } + private func addCommonHeaders(to request: inout URLRequest) { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version") @@ -74,17 +126,19 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version") } - private func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? { + static func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? { guard let snapshot else { return nil } guard !snapshot.isPlaceholder else { return nil } guard snapshot.hasPercentRemaining else { return nil } - // percent_remaining is 0-100 based on the JSON example in the web app source - let usedPercent = max(0, 100 - snapshot.percentRemaining) + let usedPercent = snapshot.usedPercent + let overQuotaDescription = snapshot.overQuotaUsedPercent.map { used in + String(format: "%.0f%% used", used) + } return RateWindow( usedPercent: usedPercent, windowMinutes: nil, // Not provided resetsAt: nil, // Not provided per-quota in the simplified snapshot - resetDescription: nil) + resetDescription: overQuotaDescription) } } diff --git a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift new file mode 100644 index 000000000..3e43e0785 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum CrofProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .crof, + metadata: ProviderMetadata( + id: .crof, + displayName: "Crof", + sessionLabel: "Requests", + weeklyLabel: "Credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "Credit balance from the Crof usage API", + toggleTitle: "Show Crof usage", + cliName: "crof", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://crof.ai/dashboard", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .crof, + iconResourceName: "ProviderIcon-crof", + color: ProviderColor(red: 0.18, green: 0.67, blue: 0.58)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Crof cost summary is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CrofAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "crof", + aliases: ["crofai"], + versionDetector: nil)) + } +} + +struct CrofAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "crof.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw CrofUsageError.missingCredentials + } + let usage = try await CrofUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.crofToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Crof/CrofSettingsReader.swift b/Sources/CodexBarCore/Providers/Crof/CrofSettingsReader.swift new file mode 100644 index 000000000..f21949129 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Crof/CrofSettingsReader.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum CrofSettingsReader { + public static let apiKeyEnvironmentKeys = ["CROF_API_KEY", "CROFAI_API_KEY"] + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let value = self.cleaned(environment[key]) { + return value + } + } + return nil + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift b/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift new file mode 100644 index 000000000..c8c3a4cac --- /dev/null +++ b/Sources/CodexBarCore/Providers/Crof/CrofUsageFetcher.swift @@ -0,0 +1,89 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct CrofUsageResponse: Decodable, Sendable { + public let credits: Double + public let requestsPlan: Double + public let usableRequests: Double + + enum CodingKeys: String, CodingKey { + case credits + case requestsPlan = "requests_plan" + case usableRequests = "usable_requests" + } +} + +public enum CrofUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(Int) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Crof API key." + case let .networkError(message): + "Crof network error: \(message)" + case let .apiError(statusCode): + "Crof API error: HTTP \(statusCode)" + case let .parseFailed(message): + "Failed to parse Crof response: \(message)" + } + } +} + +public enum CrofUsageFetcher { + public static let usageURL = URL(string: "https://crof.ai/usage_api/")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> CrofUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CrofUsageError.missingCredentials + } + + var request = URLRequest(url: self.usageURL) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw CrofUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + throw CrofUsageError.apiError(response.statusCode) + } + + return try self.parseSnapshot(response.data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> CrofUsageSnapshot { + try self.parseSnapshot(data) + } + + private static func parseSnapshot(_ data: Data) throws -> CrofUsageSnapshot { + let decoded: CrofUsageResponse + do { + decoded = try JSONDecoder().decode(CrofUsageResponse.self, from: data) + } catch { + throw CrofUsageError.parseFailed(error.localizedDescription) + } + + return CrofUsageSnapshot( + credits: decoded.credits, + requestsPlan: decoded.requestsPlan, + usableRequests: decoded.usableRequests, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift new file mode 100644 index 000000000..ee76bcb9e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Crof/CrofUsageSnapshot.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct CrofUsageSnapshot: Sendable { + private static let requestWindowMinutes = 24 * 60 + private static let resetTimeZone = TimeZone(identifier: "America/Chicago") ?? TimeZone(secondsFromGMT: -5)! + + public let credits: Double + public let requestsPlan: Double + public let usableRequests: Double + public let updatedAt: Date + + public init( + credits: Double, + requestsPlan: Double, + usableRequests: Double, + updatedAt: Date = Date()) + { + self.credits = credits + self.requestsPlan = requestsPlan + self.usableRequests = usableRequests + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double + if self.requestsPlan > 0 { + let usableRequests = max(0, min(self.requestsPlan, self.usableRequests)) + let remainingPercent = floor(usableRequests / self.requestsPlan * 100).clamped(to: 0...100) + usedPercent = 100 - remainingPercent + } else { + usedPercent = 100 + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: Self.requestWindowMinutes, + resetsAt: Self.nextRequestReset(after: self.updatedAt), + resetDescription: Self.formatRequestsLeft(self.usableRequests)) + + let creditsDetail = Self.formatCredits(self.credits) + let secondary = RateWindow( + // Crof returns a balance but no credit cap, so the bar only indicates present vs. exhausted credits. + usedPercent: self.credits > 0 ? 0 : 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: creditsDetail) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + providerCost: nil, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .crof, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API key")) + } + + private static func formatCredits(_ value: Double) -> String { + let clamped = max(0, value) + let cents = floor(clamped * 100) / 100 + return String(format: "$%.2f", cents) + } + + private static func formatRequestsLeft(_ value: Double) -> String { + let clamped = max(0, value) + let formatted = clamped.rounded() == clamped + ? String(format: "%.0f", clamped) + : String(format: "%.2f", clamped) + return "\(formatted) requests left" + } + + private static func nextRequestReset(after date: Date) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = Self.resetTimeZone + + let startOfDay = calendar.startOfDay(for: date) + return startOfDay <= date + ? calendar.date(byAdding: .day, value: 1, to: startOfDay) + : startOfDay + } +} diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 4dee402a2..bb94fdfca 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -201,6 +201,38 @@ public struct CursorUsageSummary: Codable, Sendable { public struct CursorIndividualUsage: Codable, Sendable { public let plan: CursorPlanUsage? public let onDemand: CursorOnDemandUsage? + /// Enterprise / team-member personal cap. Reported by Cursor when the account is part of a team or + /// enterprise plan with an individual quota. Values follow the same cents-based units as `plan`. + public let overall: CursorOverallUsage? + + public init( + plan: CursorPlanUsage? = nil, + onDemand: CursorOnDemandUsage? = nil, + overall: CursorOverallUsage? = nil) + { + self.plan = plan + self.onDemand = onDemand + self.overall = overall + } +} + +/// Personal cap reported under `individualUsage.overall` for Enterprise/Team members. +/// Mirrors the shape of `CursorOnDemandUsage`; values are in cents. +public struct CursorOverallUsage: Codable, Sendable { + public let enabled: Bool? + /// Usage in cents (e.g., 7384 = $73.84) + public let used: Int? + /// Limit in cents (e.g., 10000 = $100.00). `nil` indicates the API omitted a numeric cap. + public let limit: Int? + /// Remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } public struct CursorPlanUsage: Codable, Sendable { @@ -235,6 +267,31 @@ public struct CursorOnDemandUsage: Codable, Sendable { public struct CursorTeamUsage: Codable, Sendable { public let onDemand: CursorOnDemandUsage? + /// Shared team/enterprise pool counted across all members. Same cents-based units as the other usage blocks. + public let pooled: CursorPooledUsage? + + public init(onDemand: CursorOnDemandUsage? = nil, pooled: CursorPooledUsage? = nil) { + self.onDemand = onDemand + self.pooled = pooled + } +} + +/// Shared team/enterprise pool reported under `teamUsage.pooled`. Values are in cents. +public struct CursorPooledUsage: Codable, Sendable { + public let enabled: Bool? + /// Pool usage in cents. + public let used: Int? + /// Pool limit in cents. `nil` indicates an unlimited or unreported pool. + public let limit: Int? + /// Pool remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } // MARK: - Cursor Usage API Models (Legacy Request-Based Plans) @@ -613,13 +670,13 @@ public struct CursorStatusProbe: Sendable { public let baseURL: URL public var timeout: TimeInterval = 15.0 private let browserDetection: BrowserDetection - private let urlSession: URLSession + private let urlSession: any ProviderHTTPTransport public init( baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, browserDetection: BrowserDetection, - urlSession: URLSession = .shared) + urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared) { self.baseURL = baseURL self.timeout = timeout @@ -633,7 +690,10 @@ public struct CursorStatusProbe: Sendable { } /// Fetch Cursor usage using browser cookies with fallback to stored session. - public func fetch(cookieHeaderOverride: String? = nil, logger: ((String) -> Void)? = nil) + public func fetch( + cookieHeaderOverride: String? = nil, + allowCachedSessions: Bool = true, + logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot { let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } @@ -644,7 +704,8 @@ public struct CursorStatusProbe: Sendable { return try await self.fetchWithCookieHeader(override) } - if let cached = CookieHeaderCache.load(provider: .cursor), + if allowCachedSessions, + let cached = CookieHeaderCache.load(provider: .cursor), !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { log("Using cached cookie header from \(cached.sourceLabel)") @@ -701,24 +762,26 @@ public struct CursorStatusProbe: Sendable { } // Fall back to stored session cookies (from "Add Account" login flow) - let storedCookies = await CursorSessionStore.shared.getCookies() - if !storedCookies.isEmpty { - log("Using stored session cookies") - let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") - do { - return try await self.fetchWithCookieHeader(cookieHeader) - } catch let error as CursorStatusProbeError { - if case .notLoggedIn = error { - // Clear only when auth is invalid; keep for transient failures. - await CursorSessionStore.shared.clearCookies() - log("Stored session invalid, cleared") - } else { + if allowCachedSessions { + let storedCookies = await CursorSessionStore.shared.getCookies() + if !storedCookies.isEmpty { + log("Using stored session cookies") + let cookieHeader = storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + do { + return try await self.fetchWithCookieHeader(cookieHeader) + } catch let error as CursorStatusProbeError { + if case .notLoggedIn = error { + // Clear only when auth is invalid; keep for transient failures. + await CursorSessionStore.shared.clearCookies() + log("Stored session invalid, cleared") + } else { + log("Stored session failed: \(error.localizedDescription)") + firstRecoverableError = firstRecoverableError ?? error + } + } catch { log("Stored session failed: \(error.localizedDescription)") - firstRecoverableError = firstRecoverableError ?? error + firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) } - } catch { - log("Stored session failed: \(error.localizedDescription)") - firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) } } @@ -966,8 +1029,6 @@ public struct CursorStatusProbe: Sendable { // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. let planUsedRaw = Double(summary.individualUsage?.plan?.used ?? 0) let planLimitRaw = Double(summary.individualUsage?.plan?.limit ?? 0) - let planUsed = planUsedRaw / 100.0 - let planLimit = planLimitRaw / 100.0 func normPct(_ value: Double?) -> Double? { guard let v = value else { return nil } if v < 0 { return 0 } @@ -984,9 +1045,23 @@ public struct CursorStatusProbe: Sendable { let autoPercent = normPct(summary.individualUsage?.plan?.autoPercentUsed) let apiPercent = normPct(summary.individualUsage?.plan?.apiPercentUsed) - // Headline "Total" should prefer Cursor's provided totalPercentUsed when available. plan.limit is often - // the subscription price in cents, so used/limit can diverge from the dashboard usage bars. - // If totalPercentUsed is absent, fall back to averaging the Auto/API lane percents. + // Enterprise / team-member personal cap (cents). Reported under `individualUsage.overall` for accounts + // that don't get a `plan` block. Falls through to existing logic when absent so non-enterprise paths + // are untouched. + let overallUsedRaw = (summary.individualUsage?.overall?.used).map(Double.init) + let overallLimitRaw = (summary.individualUsage?.overall?.limit).map(Double.init) + + // Shared team/enterprise pool (cents). Last-resort fallback when no individual data is available. + let pooledUsedRaw = (summary.teamUsage?.pooled?.used).map(Double.init) + let pooledLimitRaw = (summary.teamUsage?.pooled?.limit).map(Double.init) + + // Headline "Total" precedence: + // 1. `individualUsage.plan.totalPercentUsed` (existing behavior for Pro/Hobby/etc.) + // 2. averaged `auto` + `api` lane percents (existing behavior) + // 3. either lane alone (existing behavior) + // 4. `individualUsage.plan` ratio (existing behavior) + // 5. NEW: `individualUsage.overall` ratio (Enterprise/Team personal cap) + // 6. NEW: `teamUsage.pooled` ratio (last resort when no individual data is reported) let planPercentUsed: Double = if let totalPercentUsed = summary.individualUsage?.plan?.totalPercentUsed { normalizeTotalPercent(totalPercentUsed) } else if let autoUsed = autoPercent, let apiUsed = apiPercent { @@ -997,10 +1072,33 @@ public struct CursorStatusProbe: Sendable { max(0, min(100, autoUsed)) } else if planLimitRaw > 0 { (planUsedRaw / planLimitRaw) * 100 + } else if let used = overallUsedRaw, let limit = overallLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) + } else if let used = pooledUsedRaw, let limit = pooledLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) } else { 0 } + // USD figures: prefer the source the headline ultimately came from. When `plan` is missing but + // `overall` or `pooled` carry the cents, surface those so the on-demand display and downstream + // consumers see real dollar amounts instead of zeros. + let planUsed: Double + let planLimit: Double + if planLimitRaw > 0 || planUsedRaw > 0 { + planUsed = planUsedRaw / 100.0 + planLimit = planLimitRaw / 100.0 + } else if let usedCents = overallUsedRaw, let limitCents = overallLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else if let usedCents = pooledUsedRaw, let limitCents = pooledLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else { + planUsed = 0 + planLimit = 0 + } + let onDemandUsed = Double(summary.individualUsage?.onDemand?.used ?? 0) / 100.0 let onDemandLimit: Double? = summary.individualUsage?.onDemand?.limit.map { Double($0) / 100.0 } @@ -1062,7 +1160,7 @@ public struct CursorStatusProbe: Sendable { baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, browserDetection: BrowserDetection, - urlSession: URLSession = .shared) + urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared) { _ = baseURL _ = timeout @@ -1077,6 +1175,7 @@ public struct CursorStatusProbe: Sendable { public func fetch( cookieHeaderOverride _: String? = nil, + allowCachedSessions _: Bool = true, logger: ((String) -> Void)? = nil) async throws -> CursorStatusSnapshot { try await self.fetch(logger: logger) diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift new file mode 100644 index 000000000..56db22889 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DeepSeekProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .deepseek, + metadata: ProviderMetadata( + id: .deepseek, + displayName: "DeepSeek", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show DeepSeek usage", + cliName: "deepseek", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.deepseek.com/usage", + statusPageURL: nil, + statusLinkURL: "https://status.deepseek.com"), + branding: ProviderBranding( + iconStyle: .deepseek, + iconResourceName: "ProviderIcon-deepseek", + color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "DeepSeek per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DeepSeekAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "deepseek", + aliases: ["deep-seek", "ds"], + versionDetector: nil)) + } +} + +struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "deepseek.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw DeepSeekUsageError.missingCredentials + } + let usage = try await DeepSeekUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.deepseekToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift new file mode 100644 index 000000000..6f622d332 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct DeepSeekSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "DEEPSEEK_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "DEEPSEEK_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift new file mode 100644 index 000000000..7365c1cd5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -0,0 +1,214 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct DeepSeekBalanceResponse: Decodable, Sendable { + public let isAvailable: Bool + public let balanceInfos: [DeepSeekBalanceInfo] + + enum CodingKeys: String, CodingKey { + case isAvailable = "is_available" + case balanceInfos = "balance_infos" + } +} + +public struct DeepSeekBalanceInfo: Decodable, Sendable { + public let currency: String + public let totalBalance: String + public let grantedBalance: String + public let toppedUpBalance: String + + enum CodingKeys: String, CodingKey { + case currency + case totalBalance = "total_balance" + case grantedBalance = "granted_balance" + case toppedUpBalance = "topped_up_balance" + } +} + +// MARK: - Domain snapshot + +public struct DeepSeekUsageSnapshot: Sendable { + public let isAvailable: Bool + public let currency: String + public let totalBalance: Double + public let grantedBalance: Double + public let toppedUpBalance: Double + public let updatedAt: Date + + public init( + isAvailable: Bool, + currency: String, + totalBalance: Double, + grantedBalance: Double, + toppedUpBalance: Double, + updatedAt: Date) + { + self.isAvailable = isAvailable + self.currency = currency + self.totalBalance = totalBalance + self.grantedBalance = grantedBalance + self.toppedUpBalance = toppedUpBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let symbol = self.currency == "CNY" ? "¥" : "$" + + let balanceDetail: String + let usedPercent: Double + if self.totalBalance <= 0 { + balanceDetail = "\(symbol)0.00 — add credits at platform.deepseek.com" + usedPercent = 100 + } else if !self.isAvailable { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 + } else { + let total = String(format: "\(symbol)%.2f", self.totalBalance) + let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) + let granted = String(format: "\(symbol)%.2f", self.grantedBalance) + balanceDetail = "\(total) (Paid: \(paid) / Granted: \(granted))" + usedPercent = 0 + } + + let identity = ProviderIdentitySnapshot( + providerID: .deepseek, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + + return UsageSnapshot( + primary: balanceWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum DeepSeekUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing DeepSeek API key." + case let .networkError(message): + "DeepSeek network error: \(message)" + case let .apiError(message): + "DeepSeek API error: \(message)" + case let .parseFailed(message): + "Failed to parse DeepSeek response: \(message)" + } + } +} + +// MARK: - Fetcher + +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 timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> DeepSeekUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DeepSeekUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + 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 { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("DeepSeek API returned \(response.statusCode): \(body)") + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("DeepSeek API response: \(jsonString)") + } + + return try Self.parseSnapshot(data: data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { + let decoded: DeepSeekBalanceResponse + do { + decoded = try JSONDecoder().decode(DeepSeekBalanceResponse.self, from: data) + } catch { + throw DeepSeekUsageError.parseFailed(error.localizedDescription) + } + + let balances = try decoded.balanceInfos.map(Self.parseBalanceInfo) + guard !balances.isEmpty else { + return DeepSeekUsageSnapshot( + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: Date()) + } + + // Prefer USD when it is funded, but do not hide a positive CNY balance behind + // an empty USD row returned by the API. + let selected = balances.first { $0.currency == "USD" && $0.totalBalance > 0 } + ?? balances.first { $0.totalBalance > 0 } + ?? balances.first { $0.currency == "USD" } + ?? balances[0] + + return DeepSeekUsageSnapshot( + isAvailable: decoded.isAvailable, + currency: selected.currency, + totalBalance: selected.totalBalance, + grantedBalance: selected.grantedBalance, + toppedUpBalance: selected.toppedUpBalance, + updatedAt: Date()) + } + + private struct ParsedBalanceInfo { + let currency: String + let totalBalance: Double + let grantedBalance: Double + let toppedUpBalance: Double + } + + private static func parseBalanceInfo(_ info: DeepSeekBalanceInfo) throws -> ParsedBalanceInfo { + guard + let total = Double(info.totalBalance), + let granted = Double(info.grantedBalance), + let toppedUp = Double(info.toppedUpBalance) + else { + throw DeepSeekUsageError.parseFailed("Non-numeric balance value in response.") + } + + return ParsedBalanceInfo( + currency: info.currency, + totalBalance: total, + grantedBalance: granted, + toppedUpBalance: toppedUp) + } +} diff --git a/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift new file mode 100644 index 000000000..8490e177b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Deepgram/DeepgramProviderDescriptor.swift @@ -0,0 +1,103 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DeepgramProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .deepgram, + metadata: ProviderMetadata( + id: .deepgram, + displayName: "Deepgram", + sessionLabel: "Requests", + weeklyLabel: "Usage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "Usage summary from Deepgram API", + toggleTitle: "Show Deepgram usage", + cliName: "deepgram", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://console.deepgram.com/project/", + statusPageURL: nil, + statusLinkURL: "https://status.deepgram.com"), + branding: ProviderBranding( + iconStyle: .deepgram, + iconResourceName: "ProviderIcon-deepgram", + color: ProviderColor( + red: 100 / 255, + green: 103 / 255, + blue: 242 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { + "Deepgram cost summary is not yet supported." + }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline( + resolveStrategies: { _ in + [DeepgramAPIFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "deepgram", + aliases: ["dg"], + versionDetector: nil)) + } +} + +struct DeepgramAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "deepgram.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveAPIKey(context) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveAPIKey(context) else { + throw DeepgramSettingsError.missingToken + } + + let usage = try await DeepgramUsageFetcher.fetchUsage( + apiKey: apiKey, + projectID: Self.resolveProjectID(context), + environment: context.env) + + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveAPIKey(_ context: ProviderFetchContext) -> String? { + ProviderTokenResolver.deepgramResolution( + type: .apiKey, + environment: context.env) + } + + private static func resolveProjectID(_ context: ProviderFetchContext) -> String? { + ProviderTokenResolver.deepgramResolution( + type: .projectID, + environment: context.env) + } +} + +/// Errors related to Deepgram settings +public enum DeepgramSettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "Deepgram API token not configured. Set DEEPGRAM_API_KEY environment variable or configure in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Deepgram/DeepgramSettingsReader.swift b/Sources/CodexBarCore/Providers/Deepgram/DeepgramSettingsReader.swift new file mode 100644 index 000000000..4ebe40242 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Deepgram/DeepgramSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct DeepgramSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "DEEPGRAM_API_KEY" + public static let projectIDEnvironmentKey = "DEEPGRAM_PROJECT_ID" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + public static func projectID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.projectIDEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Deepgram/DeepgramUsageFetcher.swift b/Sources/CodexBarCore/Providers/Deepgram/DeepgramUsageFetcher.swift new file mode 100644 index 000000000..cd8e713b2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Deepgram/DeepgramUsageFetcher.swift @@ -0,0 +1,548 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum DeepgramUsageError: LocalizedError, Sendable { + case missingAPIKey + case invalidCredentials + case invalidProjectID + case forbidden(String) + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingAPIKey: + "Missing Deepgram API key. Set apiKey in ~/.codexbar/config.json or DEEPGRAM_API_KEY." + case .invalidCredentials: + "Deepgram API key is invalid or expired." + case .invalidProjectID: + "Deepgram project ID is invalid or no projects were returned for this API key." + case let .forbidden(message): + "Deepgram rejected access: \(message)" + case let .networkError(message): + "Deepgram network error: \(message)" + case let .apiError(message): + "Deepgram API error: \(message)" + case let .parseFailed(message): + "Deepgram parse error: \(message)" + } + } +} + +// MARK: - API Responses + +public struct DeepgramProjectsResponse: Decodable, Sendable { + public let projects: [DeepgramProject] +} + +public struct DeepgramProject: Decodable, Sendable, Equatable { + public let projectID: String + public let name: String? + + private enum CodingKeys: String, CodingKey { + case projectID = "project_id" + case name + } + + public init(projectID: String, name: String? = nil) { + self.projectID = projectID + self.name = name + } +} + +public struct DeepgramUsageResponse: Decodable, Sendable { + public let start: String? + public let end: String? + public let resolution: DeepgramUsageResolution? + public let results: [DeepgramUsageResult] +} + +public struct DeepgramUsageResolution: Decodable, Sendable { + public let units: String? + public let amount: Int? +} + +public struct DeepgramUsageResult: Codable, Sendable { + public let start: String? + public let end: String? + public let hours: Double? + public let totalHours: Double? + public let agentHours: Double? + public let tokensIn: Int? + public let tokensOut: Int? + public let ttsCharacters: Int? + public let requests: Int? + + private enum CodingKeys: String, CodingKey { + case start + case end + case hours + case totalHours = "total_hours" + case agentHours = "agent_hours" + case tokensIn = "tokens_in" + case tokensOut = "tokens_out" + case ttsCharacters = "tts_characters" + case requests + } +} + +// MARK: - Query + +public struct DeepgramUsageQuery: Sendable { + public var start: String? + public var end: String? + + public init( + start: String? = nil, + end: String? = nil) + { + self.start = start + self.end = end + } + + public func queryItems() -> [URLQueryItem] { + var items: [URLQueryItem] = [] + + func add(_ name: String, _ value: String?) { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return + } + items.append(URLQueryItem(name: name, value: value)) + } + + add("start", self.start) + add("end", self.end) + + return items + } +} + +// MARK: - Snapshot + +public struct DeepgramUsageSnapshot: Codable, Sendable { + public let projectID: String + public let projectName: String? + public let projectCount: Int + public let start: String? + public let end: String? + public let hours: Double + public let totalHours: Double + public let agentHours: Double + public let tokensIn: Int + public let tokensOut: Int + public let ttsCharacters: Int + public let requests: Int + public let updatedAt: Date + + public init( + projectID: String, + projectName: String? = nil, + projectCount: Int = 1, + start: String?, + end: String?, + hours: Double, + totalHours: Double, + agentHours: Double = 0, + tokensIn: Int = 0, + tokensOut: Int = 0, + ttsCharacters: Int = 0, + requests: Int, + updatedAt: Date) + { + self.projectID = projectID + self.projectName = projectName + self.projectCount = projectCount + self.start = start + self.end = end + self.hours = hours + self.totalHours = totalHours + self.agentHours = agentHours + self.tokensIn = tokensIn + self.tokensOut = tokensOut + self.ttsCharacters = ttsCharacters + self.requests = requests + self.updatedAt = updatedAt + } +} + +extension DeepgramUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .deepgram, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.identityLabel) + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + deepgramUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + public var displayLines: [String] { + var lines: [String] = [] + lines.append("Requests: \(Self.formatInteger(self.requests))") + + var usageParts: [String] = [] + if self.hours > 0 { + usageParts.append("\(Self.formatDecimal(self.hours)) audio hours") + } + if self.totalHours > 0 { + usageParts.append("\(Self.formatDecimal(self.totalHours)) billable hours") + } + if !usageParts.isEmpty { + lines.append(usageParts.joined(separator: " · ")) + } + + var modelParts: [String] = [] + if self.agentHours > 0 { + modelParts.append("\(Self.formatDecimal(self.agentHours)) agent hours") + } + if self.tokensIn > 0 || self.tokensOut > 0 { + modelParts.append("\(Self.formatInteger(self.tokensIn + self.tokensOut)) tokens") + } + if self.ttsCharacters > 0 { + modelParts.append("\(Self.formatInteger(self.ttsCharacters)) TTS chars") + } + if !modelParts.isEmpty { + lines.append(modelParts.joined(separator: " · ")) + } + + if let start, let end { + lines.append("Period: \(start) to \(end)") + } + + return lines + } + + private var identityLabel: String? { + if self.projectCount > 1 { + return "\(self.projectCount) projects" + } + if let projectName = self.projectName?.trimmingCharacters(in: .whitespacesAndNewlines), + !projectName.isEmpty + { + return "Project: \(projectName)" + } + return "Project: \(self.projectID)" + } + + fileprivate static func aggregate( + _ snapshots: [DeepgramUsageSnapshot], + updatedAt: Date) throws -> DeepgramUsageSnapshot + { + guard let first = snapshots.first else { + throw DeepgramUsageError.invalidProjectID + } + if snapshots.count == 1 { return first } + return DeepgramUsageSnapshot( + projectID: "all", + projectName: nil, + projectCount: snapshots.count, + start: snapshots.compactMap(\.start).min(), + end: snapshots.compactMap(\.end).max(), + hours: snapshots.reduce(0) { $0 + $1.hours }, + totalHours: snapshots.reduce(0) { $0 + $1.totalHours }, + agentHours: snapshots.reduce(0) { $0 + $1.agentHours }, + tokensIn: snapshots.reduce(0) { $0 + $1.tokensIn }, + tokensOut: snapshots.reduce(0) { $0 + $1.tokensOut }, + ttsCharacters: snapshots.reduce(0) { $0 + $1.ttsCharacters }, + requests: snapshots.reduce(0) { $0 + $1.requests }, + updatedAt: updatedAt) + } + + private static func formatInteger(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.groupingSeparator = "," + formatter.maximumFractionDigits = 0 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + private static func formatDecimal(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.groupingSeparator = "," + formatter.minimumFractionDigits = value == floor(value) ? 0 : 1 + formatter.maximumFractionDigits = 1 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + } +} + +// MARK: - Fetcher + +public struct DeepgramUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.deepgramUsage) + private static let defaultBaseURL = URL(string: "https://api.deepgram.com/v1")! + + private struct FetchContext { + let apiKey: String + let query: DeepgramUsageQuery + let timeout: TimeInterval + let environment: [String: String] + let transport: ProviderHTTPTransport + let updatedAt: Date + } + + public static func fetchUsage( + apiKey: String, + projectID: String? = nil, + query: DeepgramUsageQuery = DeepgramUsageQuery(), + timeout: TimeInterval = 15, + environment: [String: String] = ProcessInfo.processInfo.environment, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> DeepgramUsageSnapshot + { + let cleanedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanedAPIKey.isEmpty else { + throw DeepgramUsageError.missingAPIKey + } + + let updatedAt = Date() + let context = FetchContext( + apiKey: cleanedAPIKey, + query: query, + timeout: timeout, + environment: environment, + transport: transport, + updatedAt: updatedAt) + + if let cleanedProjectID = self.cleaned(projectID) { + return try await self.fetchUsage( + project: DeepgramProject(projectID: cleanedProjectID), + context: context) + } + + let projects = try await self.listProjects( + apiKey: cleanedAPIKey, + timeout: timeout, + environment: environment, + transport: transport) + guard !projects.isEmpty else { + throw DeepgramUsageError.invalidProjectID + } + + var snapshots: [DeepgramUsageSnapshot] = [] + snapshots.reserveCapacity(projects.count) + for project in projects { + let snapshot = try await self.fetchUsage( + project: project, + context: context) + snapshots.append(snapshot) + } + return try DeepgramUsageSnapshot.aggregate(snapshots, updatedAt: updatedAt) + } + + static func _parseSnapshotForTesting( + _ data: Data, + projectID: String = "project-test", + projectName: String? = nil, + updatedAt: Date = Date()) throws -> DeepgramUsageSnapshot + { + try self.parseUsage( + data: data, + project: DeepgramProject(projectID: projectID, name: projectName), + updatedAt: updatedAt) + } + + private static func listProjects( + apiKey: String, + timeout: TimeInterval, + environment: [String: String], + transport: ProviderHTTPTransport) async throws -> [DeepgramProject] + { + let url = self.apiURL(environment: environment).appendingPathComponent("projects") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue("Token \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await self.perform(request: request, transport: transport) + do { + return try JSONDecoder().decode(DeepgramProjectsResponse.self, from: response.data).projects + } catch { + Self.log.error("Deepgram projects decode failed: \(error.localizedDescription)") + throw DeepgramUsageError.parseFailed(error.localizedDescription) + } + } + + private static func fetchUsage( + project: DeepgramProject, + context: FetchContext) async throws -> DeepgramUsageSnapshot + { + let url = try self.usageURL( + projectID: project.projectID, + query: context.query, + environment: context.environment) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = context.timeout + request.setValue("Token \(context.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await self.perform(request: request, transport: context.transport) + return try self.parseUsage(data: response.data, project: project, updatedAt: context.updatedAt) + } + + private static func perform( + request: URLRequest, + transport: ProviderHTTPTransport) async throws -> ProviderHTTPResponse + { + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw DeepgramUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + let summary = self.responseSummary(response.data) + Self.log.error("Deepgram returned HTTP \(response.statusCode): \(summary)") + + switch response.statusCode { + case 401: + throw DeepgramUsageError.invalidCredentials + case 403: + throw DeepgramUsageError.forbidden( + "The API key may not have access to the project or the Management API. HTTP 403: \(summary)") + case 400: + throw DeepgramUsageError.apiError("Bad request. HTTP 400: \(summary)") + default: + throw DeepgramUsageError.apiError("HTTP \(response.statusCode): \(summary)") + } + } + + return response + } + + private static func usageURL( + projectID: String, + query: DeepgramUsageQuery, + environment: [String: String]) throws -> URL + { + let usageURL = self.apiURL(environment: environment) + .appendingPathComponent("projects") + .appendingPathComponent(projectID) + .appendingPathComponent("usage") + .appendingPathComponent("breakdown") + + guard var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false) else { + throw DeepgramUsageError.networkError("Invalid usage URL") + } + + let queryItems = query.queryItems() + if !queryItems.isEmpty { + components.queryItems = queryItems + } + + guard let finalURL = components.url else { + throw DeepgramUsageError.networkError("Invalid usage query") + } + + return finalURL + } + + private static func apiURL(environment: [String: String]) -> URL { + if let raw = environment["DEEPGRAM_API_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let url = URL(string: raw) + { + return url + } + + return self.defaultBaseURL + } + + private static func parseUsage( + data: Data, + project: DeepgramProject, + updatedAt: Date) throws -> DeepgramUsageSnapshot + { + do { + let response = try JSONDecoder().decode(DeepgramUsageResponse.self, from: data) + return DeepgramUsageSnapshot( + projectID: project.projectID, + projectName: project.name, + start: response.start, + end: response.end, + hours: response.results.reduce(0) { $0 + ($1.hours ?? 0) }, + totalHours: response.results.reduce(0) { $0 + ($1.totalHours ?? 0) }, + agentHours: response.results.reduce(0) { $0 + ($1.agentHours ?? 0) }, + tokensIn: response.results.reduce(0) { $0 + ($1.tokensIn ?? 0) }, + tokensOut: response.results.reduce(0) { $0 + ($1.tokensOut ?? 0) }, + ttsCharacters: response.results.reduce(0) { $0 + ($1.ttsCharacters ?? 0) }, + requests: response.results.reduce(0) { $0 + ($1.requests ?? 0) }, + updatedAt: updatedAt) + } catch let error as DecodingError { + Self.log.error("Deepgram decoding error: \(error.localizedDescription)") + Self.log.error("Deepgram raw response: \(self.responseSummary(data))") + throw DeepgramUsageError.parseFailed(error.localizedDescription) + } catch { + Self.log.error("Deepgram parse error: \(error.localizedDescription)") + Self.log.error("Deepgram raw response: \(self.responseSummary(data))") + throw DeepgramUsageError.parseFailed(error.localizedDescription) + } + } + + private static func cleaned(_ raw: String?) -> String? { + guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private static func responseSummary(_ data: Data) -> String { + guard !data.isEmpty else { return "empty body" } + + guard let text = String(data: data, encoding: .utf8) else { + return "non-text body (\(data.count) bytes)" + } + + let cleaned = text + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard !cleaned.isEmpty else { + return "empty body" + } + + let maxLength = 240 + guard cleaned.count > maxLength else { + return self.redact(cleaned) + } + + let index = cleaned.index(cleaned.startIndex, offsetBy: maxLength) + return self.redact("\(cleaned[.. String { + let replacements: [(String, String)] = [ + (#"(?i)(token\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), + (#"(?i)(dg_[A-Za-z0-9._\-]+)"#, "[REDACTED]"), + ( + #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#, + "$1[REDACTED]$3"), + ] + + return replacements.reduce(text) { partial, replacement in + partial.replacingOccurrences( + of: replacement.0, + with: replacement.1, + options: .regularExpression) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift new file mode 100644 index 000000000..69fb57c89 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift @@ -0,0 +1,69 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DoubaoProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .doubao, + metadata: ProviderMetadata( + id: .doubao, + displayName: "Doubao", + sessionLabel: "Requests", + weeklyLabel: "Rate limit", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Doubao usage", + cliName: "doubao", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement?LLM=%7B%7D&advancedActiveKey=subscribe", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .doubao, + iconResourceName: "ProviderIcon-doubao", + color: ProviderColor(red: 51 / 255, green: 112 / 255, blue: 255 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Doubao cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DoubaoAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "doubao", + aliases: ["volcengine", "ark", "bytedance"], + versionDetector: nil)) + } +} + +struct DoubaoAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "doubao.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw DoubaoUsageError.missingCredentials + } + let usage = try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.doubaoToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift new file mode 100644 index 000000000..43568d4a5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoSettingsReader.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct DoubaoSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "ARK_API_KEY", + "VOLCENGINE_API_KEY", + "DOUBAO_API_KEY", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift new file mode 100644 index 000000000..9bfad55db --- /dev/null +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -0,0 +1,287 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct DoubaoUsageSnapshot: Sendable { + public let remainingRequests: Int + public let limitRequests: Int + public let resetTime: Date? + public let updatedAt: Date + public let apiKeyValid: Bool + public let totalTokens: Int? + public init( + remainingRequests: Int, + limitRequests: Int, + resetTime: Date?, + updatedAt: Date, + apiKeyValid: Bool = false, + totalTokens: Int? = nil) + { + self.remainingRequests = remainingRequests + self.limitRequests = limitRequests + self.resetTime = resetTime + self.updatedAt = updatedAt + self.apiKeyValid = apiKeyValid + self.totalTokens = totalTokens + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double + let resetDescription: String + + if self.limitRequests > 0 { + let used = max(0, self.limitRequests - self.remainingRequests) + usedPercent = min(100, max(0, Double(used) / Double(self.limitRequests) * 100)) + resetDescription = "\(used)/\(self.limitRequests) requests" + } else if self.apiKeyValid { + usedPercent = 0 + resetDescription = "Active - check dashboard for details" + } else { + usedPercent = 0 + resetDescription = "No usage data" + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.resetTime, + resetDescription: resetDescription) + + let identity = ProviderIdentitySnapshot( + providerID: .doubao, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum DoubaoUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(Int, String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Doubao API key (ARK_API_KEY)." + case let .networkError(message): + "Doubao network error: \(message)" + case let .apiError(code, message): + "Doubao API error (\(code)): \(message)" + case let .parseFailed(message): + "Failed to parse Doubao response: \(message)" + } + } +} + +public struct DoubaoUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.doubaoUsage) + private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")! + + /// Models to probe, ordered by likelihood. We try multiple models because + /// different key types may not have access to every model. + private static let probeModels = [ + "doubao-seed-2.0-code", + "doubao-1.5-pro-32k", + "doubao-lite-32k", + ] + + public static func fetchUsage(apiKey: String) async throws -> DoubaoUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DoubaoUsageError.missingCredentials + } + + var lastError: Error? + for model in self.probeModels { + do { + return try await self.probe(apiKey: apiKey, model: model) + } catch let error as DoubaoUsageError { + if case let .apiError(code, _) = error, code == 404 || code == 403 { + Self.log.debug("Doubao probe model \(model) unavailable (\(code)), trying next") + lastError = error + continue + } + throw error + } + } + throw lastError ?? DoubaoUsageError.apiError(0, "All probe models failed") + } + + private static func probe(apiKey: String, model: String) async throws -> DoubaoUsageSnapshot { + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + let body: [String: Any] = [ + "model": model, + "max_tokens": 1, + "messages": [ + ["role": "user", "content": "hi"], + ] as [[String: Any]], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + + // Accept both 200 (success) and 429 (rate limited) – both carry rate limit headers. + guard response.statusCode == 200 || response.statusCode == 429 else { + let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: data) + Self.log.error("Doubao API returned \(response.statusCode): \(summary)") + throw DoubaoUsageError.apiError(response.statusCode, summary) + } + + let headers = response.response.allHeaderFields + let remaining = Self.intHeader(headers, "x-ratelimit-remaining-requests") + let limit = Self.intHeader(headers, "x-ratelimit-limit-requests") + let resetString = Self.stringHeader(headers, "x-ratelimit-reset-requests") + + let resetTime: Date? = resetString.flatMap(Self.parseResetTime) + + var totalTokens: Int? + if remaining == nil, limit == nil, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let usage = json["usage"] as? [String: Any] + { + totalTokens = usage["total_tokens"] as? Int + } + + // 429 means the key is valid but rate-limited; treat it as valid so the UI + // shows "Active" instead of "No usage data" when headers are absent. + let keyValid = response.statusCode == 200 || response.statusCode == 429 + + let snapshot = DoubaoUsageSnapshot( + remainingRequests: remaining ?? 0, + limitRequests: limit ?? 0, + resetTime: resetTime, + updatedAt: Date(), + apiKeyValid: keyValid, + totalTokens: totalTokens) + + Self.log.debug( + """ + Doubao usage parsed remaining=\(snapshot.remainingRequests) \ + limit=\(snapshot.limitRequests) valid=\(snapshot.apiKeyValid) + """) + + return snapshot + } + + private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? { + if let value = headers[name] as? String { return value } + for (key, val) in headers { + if let keyStr = key as? String, + keyStr.caseInsensitiveCompare(name) == .orderedSame, + let valStr = val as? String + { + return valStr + } + } + return nil + } + + private static func intHeader(_ headers: [AnyHashable: Any], _ name: String) -> Int? { + if let value = headers[name] as? String, let int = Int(value) { + return int + } + if let value = headers[name.lowercased()] as? String, let int = Int(value) { + return int + } + for (key, val) in headers { + if let keyStr = key as? String, + keyStr.lowercased() == name.lowercased(), + let valStr = val as? String, + let int = Int(valStr) + { + return int + } + } + return nil + } + + private static func parseResetTime(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = isoFormatter.date(from: trimmed) { return date } + let isoFallback = ISO8601DateFormatter() + isoFallback.formatOptions = [.withInternetDateTime] + if let date = isoFallback.date(from: trimmed) { return date } + + var seconds: TimeInterval = 0 + let pattern = /(\d+)([dhms])/ + for match in trimmed.matches(of: pattern) { + guard let num = Double(match.1) else { continue } + switch match.2 { + case "d": seconds += num * 86400 + case "h": seconds += num * 3600 + case "m": seconds += num * 60 + case "s": seconds += num + default: break + } + } + if seconds > 0 { + return Date().addingTimeInterval(seconds) + } + + if let secs = TimeInterval(trimmed) { + return Date().addingTimeInterval(secs) + } + + return nil + } + + private static func apiErrorSummary(statusCode: Int, data: Data) -> String { + guard let root = try? JSONSerialization.jsonObject(with: data), + let json = root as? [String: Any] + else { + if let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + { + return self.compactText(text) + } + return "Unexpected response body (\(data.count) bytes)." + } + + if let error = json["error"] as? [String: Any], + let message = error["message"] as? String + { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return Self.compactText(trimmed) } + } + + if let message = json["message"] as? String { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return Self.compactText(trimmed) } + } + + return "HTTP \(statusCode) (\(data.count) bytes)." + } + + private static func compactText(_ text: String, maxLength: Int = 200) -> String { + let collapsed = text + .components(separatedBy: .newlines) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if collapsed.count <= maxLength { return collapsed } + let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength) + return "\(collapsed[.. ProviderDescriptor { + ProviderDescriptor( + id: .elevenlabs, + metadata: ProviderMetadata( + id: .elevenlabs, + displayName: "ElevenLabs", + sessionLabel: "Credits", + weeklyLabel: "Voices", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show ElevenLabs usage", + cliName: "elevenlabs", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://elevenlabs.io/app/developers/usage", + subscriptionDashboardURL: "https://elevenlabs.io/app/subscription", + statusPageURL: nil, + statusLinkURL: "https://status.elevenlabs.io"), + branding: ProviderBranding( + iconStyle: .elevenlabs, + iconResourceName: "ProviderIcon-elevenlabs", + color: ProviderColor(red: 0.92, green: 0.92, blue: 0.90)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ElevenLabs cost history is not available via API yet." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ElevenLabsAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "elevenlabs", + aliases: ["11labs", "eleven"], + versionDetector: nil)) + } +} + +struct ElevenLabsAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "elevenlabs.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw ElevenLabsUsageError.missingCredentials + } + let usage = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.elevenLabsToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsSettingsReader.swift b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsSettingsReader.swift new file mode 100644 index 000000000..6746e983b --- /dev/null +++ b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsSettingsReader.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum ElevenLabsSettingsReader { + public static let apiKeyEnvironmentKey = "ELEVENLABS_API_KEY" + public static let apiKeyEnvironmentKeys = [ + Self.apiKeyEnvironmentKey, + "XI_API_KEY", + ] + public static let apiURLEnvironmentKey = "ELEVENLABS_API_URL" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + guard let token = self.cleaned(environment[key]) else { continue } + return token + } + return nil + } + + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = self.cleaned(environment[self.apiURLEnvironmentKey]), + let url = URL(string: override) + { + return url + } + return URL(string: "https://api.elevenlabs.io")! + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsUsageFetcher.swift b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsUsageFetcher.swift new file mode 100644 index 000000000..bdf293132 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ElevenLabs/ElevenLabsUsageFetcher.swift @@ -0,0 +1,251 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ElevenLabsOverage: Codable, Sendable, Equatable { + public let amount: String? + public let currency: String? +} + +public struct ElevenLabsSubscriptionResponse: Decodable, Sendable { + public let tier: String? + public let characterCount: Int + public let characterLimit: Int + public let voiceSlotsUsed: Int? + public let professionalVoiceSlotsUsed: Int? + public let voiceLimit: Int? + public let professionalVoiceLimit: Int? + public let currentOverage: ElevenLabsOverage? + public let status: String? + public let nextCharacterCountResetUnix: Int? + + private enum CodingKeys: String, CodingKey { + case tier + case characterCount = "character_count" + case characterLimit = "character_limit" + case voiceSlotsUsed = "voice_slots_used" + case professionalVoiceSlotsUsed = "professional_voice_slots_used" + case voiceLimit = "voice_limit" + case professionalVoiceLimit = "professional_voice_limit" + case currentOverage = "current_overage" + case status + case nextCharacterCountResetUnix = "next_character_count_reset_unix" + } +} + +public struct ElevenLabsUsageSnapshot: Codable, Sendable, Equatable { + public let tier: String? + public let characterCount: Int + public let characterLimit: Int + public let voiceSlotsUsed: Int? + public let professionalVoiceSlotsUsed: Int? + public let voiceLimit: Int? + public let professionalVoiceLimit: Int? + public let currentOverage: ElevenLabsOverage? + public let status: String? + public let resetsAt: Date? + public let updatedAt: Date + + public init( + tier: String?, + characterCount: Int, + characterLimit: Int, + voiceSlotsUsed: Int?, + professionalVoiceSlotsUsed: Int?, + voiceLimit: Int?, + professionalVoiceLimit: Int?, + currentOverage: ElevenLabsOverage?, + status: String?, + resetsAt: Date?, + updatedAt: Date) + { + self.tier = tier + self.characterCount = characterCount + self.characterLimit = characterLimit + self.voiceSlotsUsed = voiceSlotsUsed + self.professionalVoiceSlotsUsed = professionalVoiceSlotsUsed + self.voiceLimit = voiceLimit + self.professionalVoiceLimit = professionalVoiceLimit + self.currentOverage = currentOverage + self.status = status + self.resetsAt = resetsAt + self.updatedAt = updatedAt + } + + public var usedPercent: Double { + guard self.characterLimit > 0 else { return 0 } + return max(0, Double(self.characterCount) / Double(self.characterLimit) * 100) + } + + public var remainingCharacters: Int { + max(0, self.characterLimit - self.characterCount) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let primary = RateWindow( + usedPercent: self.usedPercent, + windowMinutes: nil, + resetsAt: self.resetsAt, + resetDescription: self.characterSummary) + let extraWindows = self.voiceWindows() + let identity = ProviderIdentitySnapshot( + providerID: .elevenlabs, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.displayTier) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + extraRateWindows: extraWindows.isEmpty ? nil : extraWindows, + updatedAt: self.updatedAt, + identity: identity) + } + + private var displayTier: String? { + guard let tier = tier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty else { + return status + } + let statusSuffix = if let status, !status.isEmpty, status.lowercased() != "active" { + " · \(status)" + } else { + "" + } + return "\(tier.replacingOccurrences(of: "_", with: " ").capitalized)\(statusSuffix)" + } + + private var characterSummary: String { + "\(Self.formatCount(self.characterCount)) / \(Self.formatCount(self.characterLimit)) credits" + } + + private func voiceWindows() -> [NamedRateWindow] { + var windows: [NamedRateWindow] = [] + if let used = voiceSlotsUsed, let limit = voiceLimit, limit > 0 { + windows.append(NamedRateWindow( + id: "voice-slots", + title: "Voice slots", + window: RateWindow( + usedPercent: Double(used) / Double(limit) * 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(used) / \(limit)"))) + } + if let used = professionalVoiceSlotsUsed, let limit = professionalVoiceLimit, limit > 0 { + windows.append(NamedRateWindow( + id: "professional-voices", + title: "Professional voices", + window: RateWindow( + usedPercent: Double(used) / Double(limit) * 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(used) / \(limit)"))) + } + return windows + } + + private static func formatCount(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.groupingSeparator = "," + formatter.maximumFractionDigits = 0 + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +} + +public enum ElevenLabsUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing ElevenLabs API key. Set apiKey in ~/.codexbar/config.json or ELEVENLABS_API_KEY." + case let .networkError(message): + "ElevenLabs network error: \(message)" + case let .apiError(message): + "ElevenLabs API error: \(message)" + case let .parseFailed(message): + "Failed to parse ElevenLabs response: \(message)" + } + } +} + +public struct ElevenLabsUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.elevenLabsUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ElevenLabsUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw ElevenLabsUsageError.missingCredentials + } + + let url = Self.subscriptionURL(baseURL: ElevenLabsSettingsReader.apiURL(environment: environment)) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(trimmed, forHTTPHeaderField: "xi-api-key") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + switch response.statusCode { + case 200: + return try Self.parseSnapshot(data: response.data, updatedAt: Date()) + case 401, 403: + throw ElevenLabsUsageError.missingCredentials + default: + Self.log.error("ElevenLabs API returned \(response.statusCode)") + throw ElevenLabsUsageError.apiError("HTTP \(response.statusCode)") + } + } + + static func _parseSnapshotForTesting(_ data: Data, updatedAt: Date) throws -> ElevenLabsUsageSnapshot { + try self.parseSnapshot(data: data, updatedAt: updatedAt) + } + + private static func parseSnapshot(data: Data, updatedAt: Date) throws -> ElevenLabsUsageSnapshot { + let decoded: ElevenLabsSubscriptionResponse + do { + decoded = try JSONDecoder().decode(ElevenLabsSubscriptionResponse.self, from: data) + } catch { + throw ElevenLabsUsageError.parseFailed(error.localizedDescription) + } + + let resetsAt = decoded.nextCharacterCountResetUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + + return ElevenLabsUsageSnapshot( + tier: decoded.tier, + characterCount: decoded.characterCount, + characterLimit: decoded.characterLimit, + voiceSlotsUsed: decoded.voiceSlotsUsed, + professionalVoiceSlotsUsed: decoded.professionalVoiceSlotsUsed, + voiceLimit: decoded.voiceLimit, + professionalVoiceLimit: decoded.professionalVoiceLimit, + currentOverage: decoded.currentOverage, + status: decoded.status, + resetsAt: resetsAt, + updatedAt: updatedAt) + } + + private static func subscriptionURL(baseURL: URL) -> URL { + var url = baseURL + let pathComponents = url.path.split(separator: "/") + if pathComponents.last == "v1" { + url.append(path: "user/subscription") + } else { + url.append(path: "v1/user/subscription") + } + return url + } +} diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift b/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift new file mode 100644 index 000000000..ae4698a53 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift @@ -0,0 +1,58 @@ +import Foundation + +extension FactoryStatusProbe { + struct ManualCredentials { + let cookieHeader: String? + let bearerToken: String? + } + + static func manualCredentials(from raw: String?) -> ManualCredentials? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let normalizedCookieHeader = CookieHeaderNormalizer.normalize(raw) + let cookieHeader = normalizedCookieHeader.flatMap { + CookieHeaderNormalizer.pairs(from: $0).isEmpty ? nil : $0 + } + let bearerToken = self.authorizationBearerToken(from: raw) + ?? normalizedCookieHeader.flatMap(self.bearerToken(fromHeader:)) + ?? self.bareBearerToken(from: raw) + guard cookieHeader != nil || bearerToken != nil else { return nil } + return ManualCredentials(cookieHeader: cookieHeader, bearerToken: bearerToken) + } + + static func bearerToken(fromHeader cookieHeader: String) -> String? { + for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "access-token" { + let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { return token } + } + return nil + } + + private static func authorizationBearerToken(from raw: String) -> String? { + let pattern = #"(?i)(?:authorization\s*:\s*)?bearer\s+([A-Za-z0-9._~+/=-]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(raw.startIndex..= 2, + let tokenRange = Range(match.range(at: 1), in: raw) + else { + return nil + } + let token = raw[tokenRange].trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : String(token) + } + + private static func bareBearerToken(from raw: String) -> String? { + let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.contains("="), + !token.contains(";"), + !token.contains(" "), + !token.contains("\n"), + token.count >= 40 || token.split(separator: ".").count >= 3 + else { + return nil + } + return token + } +} diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift index dbbe1509d..815cd537a 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift @@ -65,6 +65,6 @@ struct FactoryStatusFetchStrategy: ProviderFetchStrategy { private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { guard context.settings?.factory?.cookieSource == .manual else { return nil } - return CookieHeaderNormalizer.normalize(context.settings?.factory?.manualCookieHeader) + return context.settings?.factory?.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index 0a75cf919..e3b1fb230 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -138,6 +138,12 @@ public enum FactoryCookieImporter { public struct FactoryAuthResponse: Codable, Sendable { public let featureFlags: FactoryFeatureFlags? public let organization: FactoryOrganization? + public let userProfile: FactoryUserProfile? +} + +public struct FactoryUserProfile: Codable, Sendable { + public let id: String? + public let email: String? } public struct FactoryFeatureFlags: Codable, Sendable { @@ -195,6 +201,117 @@ public struct FactoryTokenUsage: Codable, Sendable { public let orgOverageLimit: Int64? } +public struct FactoryBillingLimitsResponse: Codable, Sendable { + public let usesTokenRateLimitsBilling: Bool + public let limits: FactoryTokenRateLimits? + public let extraUsageBalanceCents: Int + public let overagePreference: String? + public let extraUsageAllowed: Bool + public let tokenRateLimitsRolloutEligible: Bool + + enum CodingKeys: String, CodingKey { + case usesTokenRateLimitsBilling + case limits + case extraUsageBalanceCents + case overagePreference + case extraUsageAllowed + case tokenRateLimitsRolloutEligible + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.usesTokenRateLimitsBilling = try container + .decodeIfPresent(Bool.self, forKey: .usesTokenRateLimitsBilling) ?? false + self.limits = try container.decodeIfPresent(FactoryTokenRateLimits.self, forKey: .limits) + self.extraUsageBalanceCents = try container.decodeIfPresent(Int.self, forKey: .extraUsageBalanceCents) ?? 0 + self.overagePreference = try container.decodeIfPresent(String.self, forKey: .overagePreference) + self.extraUsageAllowed = try container.decodeIfPresent(Bool.self, forKey: .extraUsageAllowed) ?? false + self.tokenRateLimitsRolloutEligible = try container + .decodeIfPresent(Bool.self, forKey: .tokenRateLimitsRolloutEligible) ?? false + } +} + +public struct FactoryTokenRateLimits: Codable, Sendable { + public let standard: FactoryLimitPool + public let core: FactoryLimitPool? +} + +public struct FactoryLimitPool: Codable, Sendable { + public let fiveHour: FactoryBillingWindow + public let weekly: FactoryBillingWindow + public let monthly: FactoryBillingWindow + + public var hasUsageData: Bool { + [self.fiveHour, self.weekly, self.monthly].contains { + $0.usedPercent > 0 || $0.windowEnd != nil || $0.secondsRemaining != nil + } + } +} + +public struct FactoryBillingWindow: Codable, Sendable { + public let usedPercent: Double + public let windowEnd: FlexibleFactoryDate? + public let secondsRemaining: Double? + + public func resetAt(now: Date) -> Date? { + if let secondsRemaining, secondsRemaining > 0 { + return now.addingTimeInterval(secondsRemaining) + } + guard let windowEnd = self.windowEnd?.date, windowEnd > now else { + return nil + } + return windowEnd + } + + public func effectiveUsedPercent(now: Date) -> Double { + // Factory can leave stale values after short rolling windows expire. The web UI treats + // that state as reset, so mirror it here instead of showing expired usage. + if self.resetAt(now: now) == nil, self.windowEnd != nil, self.secondsRemaining == nil { + return 0 + } + return min(100, max(0, self.usedPercent)) + } + + public func rateWindow(windowMinutes: Int?, title: String, now: Date) -> RateWindow { + let reset = self.resetAt(now: now) + return RateWindow( + usedPercent: self.effectiveUsedPercent(now: now), + windowMinutes: windowMinutes, + resetsAt: reset, + resetDescription: reset.map { FactoryStatusSnapshot.formatResetDate($0) }) + } +} + +public struct FlexibleFactoryDate: Codable, Sendable { + public let date: Date + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let seconds = try? container.decode(Double.self) { + self.date = Date(timeIntervalSince1970: seconds > 1e12 ? seconds / 1000.0 : seconds) + return + } + let string = try container.decode(String.self) + if let numeric = Double(string) { + self.date = Date(timeIntervalSince1970: numeric > 1e12 ? numeric / 1000.0 : numeric) + return + } + + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = fractional.date(from: string) ?? ISO8601DateFormatter().date(from: string) { + self.date = parsed + return + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid Factory date") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.date) + } +} + /// Helper for encoding arbitrary JSON public struct AnyCodable: Codable, Sendable { public init(from decoder: Decoder) throws { @@ -248,6 +365,10 @@ public struct FactoryStatusSnapshot: Sendable { public let userId: String? /// Raw JSON for debugging public let rawJSON: String? + /// New Factory token-rate-limits billing payload, when enabled for the account. + public let tokenRateLimits: FactoryTokenRateLimits? + public let extraUsageBalanceCents: Int? + public let overagePreference: String? public init( standardUserTokens: Int64, @@ -265,7 +386,10 @@ public struct FactoryStatusSnapshot: Sendable { organizationName: String?, accountEmail: String?, userId: String?, - rawJSON: String?) + rawJSON: String?, + tokenRateLimits: FactoryTokenRateLimits? = nil, + extraUsageBalanceCents: Int? = nil, + overagePreference: String? = nil) { self.standardUserTokens = standardUserTokens self.standardOrgTokens = standardOrgTokens @@ -283,10 +407,17 @@ public struct FactoryStatusSnapshot: Sendable { self.accountEmail = accountEmail self.userId = userId self.rawJSON = rawJSON + self.tokenRateLimits = tokenRateLimits + self.extraUsageBalanceCents = extraUsageBalanceCents + self.overagePreference = overagePreference } /// Convert to UsageSnapshot for the common provider interface public func toUsageSnapshot() -> UsageSnapshot { + if let tokenRateLimits { + return self.tokenRateLimitsUsageSnapshot(from: tokenRateLimits) + } + // Primary: Standard tokens used (as percentage of allowance, capped reasonably) let standardPercent = self.calculateUsagePercent( used: self.standardUserTokens, @@ -337,12 +468,75 @@ public struct FactoryStatusSnapshot: Sendable { identity: identity) } + private func tokenRateLimitsUsageSnapshot(from limits: FactoryTokenRateLimits) -> UsageSnapshot { + let now = Date() + let primary = limits.standard.fiveHour.rateWindow(windowMinutes: 5 * 60, title: "5h", now: now) + let secondary = limits.standard.weekly.rateWindow(windowMinutes: 7 * 24 * 60, title: "7-day", now: now) + let tertiary = limits.standard.monthly.rateWindow(windowMinutes: nil, title: "Monthly", now: now) + + let coreWindows: [NamedRateWindow]? = if let core = limits.core, core.hasUsageData { + [ + NamedRateWindow( + id: "factory-core-5h", + title: "Core 5h", + window: core.fiveHour.rateWindow(windowMinutes: 5 * 60, title: "Core 5h", now: now)), + NamedRateWindow( + id: "factory-core-7d", + title: "Core 7-day", + window: core.weekly.rateWindow(windowMinutes: 7 * 24 * 60, title: "Core 7-day", now: now)), + NamedRateWindow( + id: "factory-core-monthly", + title: "Core Monthly", + window: core.monthly.rateWindow(windowMinutes: nil, title: "Core Monthly", now: now)), + ] + } else { + nil + } + + let loginMethod: String? = { + var parts: [String] = [] + if let tier = self.tier, !tier.isEmpty { + parts.append("Factory \(tier.capitalized)") + } + if let plan = self.planName, !plan.isEmpty, !plan.lowercased().contains("factory") { + parts.append(plan) + } + if let overagePreference, !overagePreference.isEmpty { + parts.append("Fallback: \(overagePreference)") + } + return parts.isEmpty ? nil : parts.joined(separator: " - ") + }() + + let identity = ProviderIdentitySnapshot( + providerID: .factory, + accountEmail: self.accountEmail, + accountOrganization: self.organizationName, + loginMethod: loginMethod) + let providerCost = self.extraUsageBalanceCents.map { + ProviderCostSnapshot( + used: Double($0) / 100.0, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: now) + } + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: coreWindows, + providerCost: providerCost, + updatedAt: now, + identity: identity) + } + private func calculateUsagePercent(used: Int64, allowance: Int64, apiRatio: Double?) -> Double { // Prefer API-provided ratio when available and valid. // This handles plan-specific limits correctly on the server side, // avoiding issues with missing/sentinel values in totalAllowance. let unlimitedThreshold: Int64 = 1_000_000_000_000 if let ratio = apiRatio, + !(ratio == 0 && used > 0 && allowance > 0 && allowance <= unlimitedThreshold), let percent = Self.percentFromAPIRatio(ratio, allowance: allowance, unlimitedThreshold: unlimitedThreshold) { return percent @@ -383,7 +577,7 @@ public struct FactoryStatusSnapshot: Sendable { return nil } - private static func formatResetDate(_ date: Date) -> String { + static func formatResetDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "MMM d 'at' h:mma" formatter.locale = Locale(identifier: "en_US_POSIX") @@ -641,24 +835,33 @@ public struct FactoryStatusProbe: Sendable { let log: (String) -> Void = { msg in logger?("[factory] \(msg)") } var lastError: Error? - if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { - log("Using manual cookie header") - let bearer = Self.bearerToken(fromHeader: override) - let candidates = [ - self.baseURL, - Self.authBaseURL, - Self.apiBaseURL, - ] - for baseURL in candidates { - do { - return try await self.fetchWithCookieHeader( - override, - bearerToken: bearer, - baseURL: baseURL) - } catch { - lastError = error + let manualOverride = cookieHeaderOverride?.trimmingCharacters(in: .whitespacesAndNewlines) + if manualOverride?.isEmpty == false { + guard let override = Self.manualCredentials(from: manualOverride) else { + throw FactoryStatusProbeError.noSessionCookie + } + if let cookieHeader = override.cookieHeader { + log("Using manual cookie header") + let candidates = [ + self.baseURL, + Self.authBaseURL, + Self.apiBaseURL, + ] + for baseURL in candidates { + do { + return try await self.fetchWithCookieHeader( + cookieHeader, + bearerToken: override.bearerToken, + baseURL: baseURL) + } catch { + lastError = error + } } } + if let bearerToken = override.bearerToken { + log("Using manual Factory bearer token") + return try await self.fetchWithBearerToken(bearerToken, logger: log) + } if let lastError { throw lastError } throw FactoryStatusProbeError.noSessionCookie } @@ -1003,14 +1206,6 @@ public struct FactoryStatusProbe: Sendable { cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } - private static func bearerToken(fromHeader cookieHeader: String) -> String? { - for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "access-token" { - let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !token.isEmpty { return token } - } - return nil - } - private func fetchWithCookieHeader( _ cookieHeader: String, bearerToken: String?, @@ -1022,8 +1217,21 @@ public struct FactoryStatusProbe: Sendable { bearerToken: bearerToken, baseURL: baseURL) - // Extract user ID from JWT in the auth response or use a default endpoint - let userId = self.extractUserIdFromAuth(authInfo) + let userId = factoryUserIdFromAuth(authInfo) + ?? factoryUserIdFromBearerToken(bearerToken) + + if let billingLimits = try await self.fetchBillingLimitsIfAvailable( + cookieHeader: cookieHeader, + bearerToken: bearerToken), + billingLimits.usesTokenRateLimitsBilling, + let tokenRateLimits = billingLimits.limits + { + return self.buildTokenRateLimitsSnapshot( + authInfo: authInfo, + billingLimits: billingLimits, + tokenRateLimits: tokenRateLimits, + userId: userId) + } // Fetch usage data let usageData = try await self.fetchUsage( @@ -1035,6 +1243,45 @@ public struct FactoryStatusProbe: Sendable { return self.buildSnapshot(authInfo: authInfo, usageData: usageData, userId: userId) } + private func fetchBillingLimitsIfAvailable( + cookieHeader: String, + bearerToken: String?) async throws -> FactoryBillingLimitsResponse? + { + let url = Self.apiBaseURL.appendingPathComponent("/api/billing/limits") + var request = URLRequest(url: url) + request.timeoutInterval = self.timeout + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("https://app.factory.ai", forHTTPHeaderField: "Origin") + request.setValue("https://app.factory.ai/", forHTTPHeaderField: "Referer") + request.setValue("web-app", forHTTPHeaderField: "x-factory-client") + if !cookieHeader.isEmpty { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + if let bearerToken { + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await ProviderHTTPClient.shared.data(for: request) + } catch { + return nil + } + + guard let httpResponse = response as? HTTPURLResponse else { + return nil + } + + guard httpResponse.statusCode == 200 else { + return nil + } + + return try? JSONDecoder().decode(FactoryBillingLimitsResponse.self, from: data) + } + private func fetchAuthInfo( cookieHeader: String, bearerToken: String?, @@ -1056,16 +1303,23 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") } - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if httpResponse.statusCode == 401 { throw FactoryStatusProbeError.notLoggedIn } + if httpResponse.statusCode == 403 { + let body = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let snippet = body.isEmpty ? "" : ": \(body.prefix(200))" + throw FactoryStatusProbeError.networkError("HTTP 403 Forbidden\(snippet)") + } + guard httpResponse.statusCode == 200 else { let body = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -1114,7 +1368,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1248,7 +1502,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1318,7 +1572,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await ProviderHTTPClient.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1352,12 +1606,6 @@ public struct FactoryStatusProbe: Sendable { return description.localizedCaseInsensitiveContains("missing refresh token") } - private func extractUserIdFromAuth(_ auth: FactoryAuthResponse) -> String? { - // The user ID might be in the organization or we might need to parse JWT - // For now, return nil and let the API handle it - nil - } - private func buildSnapshot( authInfo: FactoryAuthResponse, usageData: FactoryUsageResponse, @@ -1386,6 +1634,55 @@ public struct FactoryStatusProbe: Sendable { userId: userId ?? usageData.userId, rawJSON: nil) } + + private func buildTokenRateLimitsSnapshot( + authInfo: FactoryAuthResponse, + billingLimits: FactoryBillingLimitsResponse, + tokenRateLimits: FactoryTokenRateLimits, + userId: String?) -> FactoryStatusSnapshot + { + FactoryStatusSnapshot( + standardUserTokens: 0, + standardOrgTokens: 0, + standardAllowance: 0, + standardUsedRatio: nil, + premiumUserTokens: 0, + premiumOrgTokens: 0, + premiumAllowance: 0, + premiumUsedRatio: nil, + periodStart: nil, + periodEnd: nil, + planName: authInfo.organization?.subscription?.orbSubscription?.plan?.name, + tier: authInfo.organization?.subscription?.factoryTier, + organizationName: authInfo.organization?.name, + accountEmail: nil, + userId: userId, + rawJSON: nil, + tokenRateLimits: tokenRateLimits, + extraUsageBalanceCents: billingLimits.extraUsageBalanceCents, + overagePreference: billingLimits.overagePreference) + } +} + +private func factoryUserIdFromAuth(_ auth: FactoryAuthResponse) -> String? { + factoryNormalizedString(auth.userProfile?.id) +} + +private func factoryUserIdFromBearerToken(_ token: String?) -> String? { + guard let token, + let claims = UsageFetcher.parseJWT(token), + let subject = claims["sub"] as? String + else { + return nil + } + return factoryNormalizedString(subject) +} + +private func factoryNormalizedString(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value } #else diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index c71f5ca04..ff5d0ca22 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -22,6 +22,7 @@ public enum GeminiProviderDescriptor { isPrimaryProvider: false, usesAccountFallback: false, dashboardURL: "https://gemini.google.com", + changelogURL: "https://github.com/google-gemini/gemini-cli/releases", statusPageURL: nil, statusLinkURL: "https://www.google.com/appsstatus/dashboard/products/npdyhgECDJ6tB66MxXyo/history", statusWorkspaceProductID: "npdyhgECDJ6tB66MxXyo"), @@ -42,6 +43,8 @@ public enum GeminiProviderDescriptor { } struct GeminiStatusFetchStrategy: ProviderFetchStrategy { + static let sourceLabel = "oauth-api" + let id: String = "gemini.api" let kind: ProviderFetchKind = .apiToken @@ -54,7 +57,7 @@ struct GeminiStatusFetchStrategy: ProviderFetchStrategy { let snap = try await probe.fetch() return self.makeResult( usage: snap.toUsageSnapshot(), - sourceLabel: "api") + sourceLabel: Self.sourceLabel) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe+DataLoader.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe+DataLoader.swift new file mode 100644 index 000000000..502f4960c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe+DataLoader.swift @@ -0,0 +1,131 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension GeminiStatusProbe { + public static func defaultDataLoader(for request: URLRequest) async throws -> (Data, URLResponse) { + let loader = Self.dataLoaderWithCurlFallback( + primary: { request in + try await ProviderHTTPClient.shared.data(for: request) + }, + fallback: Self.curlDataLoader) + return try await loader(request) + } + + public static func dataLoaderWithCurlFallback( + primary: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse), + fallback: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + -> @Sendable (URLRequest) async throws -> (Data, URLResponse) + { + { request in + do { + return try await primary(request) + } catch { + guard Self.isURLSessionTimeout(error) else { + throw error + } + CodexBarLog.logger(LogCategories.geminiProbe) + .warning("Gemini URLSession timed out; retrying with curl") + return try await fallback(request) + } + } + } + + private static func isURLSessionTimeout(_ error: Error) -> Bool { + if let urlError = error as? URLError { + return urlError.code == .timedOut + } + + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut + } + + private static func curlDataLoader(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let url = request.url else { + throw URLError(.badURL) + } + + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory + .appendingPathComponent("codexbar-gemini-curl-\(UUID().uuidString)", isDirectory: true) + try fileManager.createDirectory( + at: tempDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + defer { + try? fileManager.removeItem(at: tempDir) + } + + let configURL = tempDir.appendingPathComponent("curl.conf") + var config = [ + "silent", + "show-error", + "location", + "url = \(Self.curlConfigQuote(url.absoluteString))", + "max-time = \(max(1, Int(ceil(request.timeoutInterval))))", + ] + + if let method = request.httpMethod, !method.isEmpty { + config.append("request = \(Self.curlConfigQuote(method))") + } + + for (name, value) in (request.allHTTPHeaderFields ?? [:]).sorted(by: { $0.key < $1.key }) { + let header = "\(name): \(value)" + guard !header.contains("\n"), !header.contains("\r") else { + throw GeminiStatusProbeError.apiError("Invalid request header") + } + config.append("header = \(Self.curlConfigQuote(header))") + } + + if let body = request.httpBody { + let bodyURL = tempDir.appendingPathComponent("body") + try body.write(to: bodyURL, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bodyURL.path) + config.append("data-binary = \(Self.curlConfigQuote("@\(bodyURL.path)"))") + } + + config.append("write-out = \(Self.curlConfigQuote(Self.curlHTTPStatusMarker + "%{http_code}"))") + try config.joined(separator: "\n").write(to: configURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configURL.path) + + let result = try await SubprocessRunner.run( + binary: "/usr/bin/curl", + arguments: ["--config", configURL.path], + environment: TTYCommandRunner.enrichedEnvironment(), + timeout: max(5, request.timeoutInterval + 2), + label: "gemini-api-curl") + + return try Self.parseCurlDataLoaderResult(result.stdout, url: url) + } + + private static let curlHTTPStatusMarker = "__CODEXBAR_HTTP_STATUS__:" + + private static func curlConfigQuote(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + private static func parseCurlDataLoaderResult(_ output: String, url: URL) throws -> (Data, URLResponse) { + guard let markerRange = output.range(of: curlHTTPStatusMarker, options: .backwards) else { + throw GeminiStatusProbeError.apiError("curl response missing HTTP status") + } + + let body = String(output[.. 0, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil) + else { + throw GeminiStatusProbeError.apiError("curl response had invalid HTTP status") + } + + return (Data(body.utf8), response) + } +} diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 7ec634f59..245197072 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -146,9 +146,7 @@ public struct GeminiStatusProbe: Sendable { public init( timeout: TimeInterval = 10.0, homeDirectory: String = NSHomeDirectory(), - dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in - try await URLSession.shared.data(for: request) - }) + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = Self.defaultDataLoader) { self.timeout = timeout self.homeDirectory = homeDirectory @@ -212,31 +210,36 @@ public struct GeminiStatusProbe: Sendable { "now": "\(Date())", ]) - guard let storedAccessToken = creds.accessToken, !storedAccessToken.isEmpty else { - Self.log.error("No access token found") - throw GeminiStatusProbeError.notLoggedIn - } - - var accessToken = storedAccessToken - if let expiry = creds.expiryDate, expiry < Date() { - Self.log.info("Token expired; attempting refresh", metadata: [ - "expiry": "\(expiry)", - ]) + var accessToken = creds.accessToken?.isEmpty == false ? creds.accessToken : nil + var idToken = creds.idToken + let needsRefresh = accessToken == nil || creds.expiryDate.map { $0 < Date() } == true + if needsRefresh { + if accessToken == nil { + Self.log.info("No access token found; attempting refresh from stored Gemini credentials") + } else if let expiry = creds.expiryDate { + Self.log.info("Token expired; attempting refresh", metadata: [ + "expiry": "\(expiry)", + ]) + } - guard let refreshToken = creds.refreshToken else { + guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else { Self.log.error("No refresh token available") throw GeminiStatusProbeError.notLoggedIn } - accessToken = try await Self.refreshAccessToken( refreshToken: refreshToken, timeout: timeout, homeDirectory: homeDirectory, dataLoader: dataLoader) + idToken = (try? Self.loadCredentials(homeDirectory: homeDirectory).idToken) ?? idToken + } + guard let accessToken else { + Self.log.error("No access token found") + throw GeminiStatusProbeError.notLoggedIn } // Extract account info from JWT - let claims = Self.extractClaimsFromToken(creds.idToken) + let claims = Self.extractClaimsFromToken(idToken) // Load Code Assist status to get project ID and tier (aligned with CLI setupUser logic) let caStatus = await Self.loadCodeAssistStatus( @@ -658,6 +661,22 @@ public struct GeminiStatusProbe: Sendable { return globalPackageJSONURL.deletingLastPathComponent().path } + // Homebrew layout: + // /libexec/lib/node_modules/@google/gemini-cli/package.json + let homebrewPackageJSONURL = currentURL + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + if let data = try? Data(contentsOf: homebrewPackageJSONURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + json["name"] as? String == "@google/gemini-cli" + { + return homebrewPackageJSONURL.deletingLastPathComponent().path + } + let parentURL = currentURL.deletingLastPathComponent() if parentURL.path == currentURL.path { return nil @@ -720,6 +739,21 @@ public struct GeminiStatusProbe: Sendable { } } + guard let bundleFiles = try? FileManager.default.contentsOfDirectory( + at: bundleRoot, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { + return nil + } + + for url in bundleFiles where url.pathExtension == "js" && !visitedPaths.contains(url.standardizedFileURL.path) { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue } + if let credentials = Self.parseOAuthCredentials(from: content) { + return credentials + } + } + return nil } @@ -780,9 +814,9 @@ public struct GeminiStatusProbe: Sendable { } private static func parseOAuthCredentials(from content: String) -> OAuthClientCredentials? { - // Match: const OAUTH_CLIENT_ID = '...'; - let clientIdPattern = #"OAUTH_CLIENT_ID\s*=\s*['"]([\w\-\.]+)['"]\s*;"# - let secretPattern = #"OAUTH_CLIENT_SECRET\s*=\s*['"]([\w\-]+)['"]\s*;"# + // Match: const/let/var OAUTH_CLIENT_ID = '...'; + let clientIdPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_ID\s*=\s*['"]([\w\-\.]+)['"]\s*;"# + let secretPattern = #"(?:const|let|var)?\s*OAUTH_CLIENT_SECRET\s*=\s*['"]([\w\-]+)['"]\s*;"# guard let clientIdRegex = try? NSRegularExpression(pattern: clientIdPattern), let secretRegex = try? NSRegularExpression(pattern: secretPattern) diff --git a/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift b/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift new file mode 100644 index 000000000..9544808d1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokAuth.swift @@ -0,0 +1,188 @@ +import Foundation + +public struct GrokCredentials: Sendable { + public let accessToken: String + public let refreshToken: String? + public let scope: String + public let authMode: String? + public let userId: String? + public let email: String? + public let firstName: String? + public let lastName: String? + public let teamId: String? + public let oidcIssuer: String? + public let oidcClientId: String? + public let expiresAt: Date? + public let createTime: Date? + + public init( + accessToken: String, + refreshToken: String?, + scope: String, + authMode: String?, + userId: String?, + email: String?, + firstName: String?, + lastName: String?, + teamId: String?, + oidcIssuer: String?, + oidcClientId: String?, + expiresAt: Date?, + createTime: Date?) + { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.scope = scope + self.authMode = authMode + self.userId = userId + self.email = email + self.firstName = firstName + self.lastName = lastName + self.teamId = teamId + self.oidcIssuer = oidcIssuer + self.oidcClientId = oidcClientId + self.expiresAt = expiresAt + self.createTime = createTime + } + + public var displayName: String? { + let parts = [self.firstName, self.lastName].compactMap { $0?.nilIfEmpty } + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " ") + } + + public var isExpired: Bool { + guard let expiresAt else { return false } + return Date() >= expiresAt + } + + public var loginMethod: String? { + switch self.authMode?.lowercased() { + case "oidc": "SuperGrok" + case "session": "session" + case nil: nil + default: self.authMode + } + } +} + +public enum GrokCredentialsError: LocalizedError, Sendable { + case notFound + case decodeFailed(String) + case missingTokens + + public var errorDescription: String? { + switch self { + case .notFound: + "Grok auth.json not found. Run `grok login` to authenticate." + case let .decodeFailed(message): + "Failed to decode Grok credentials: \(message)" + case .missingTokens: + "Grok auth.json exists but contains no access tokens." + } + } +} + +public enum GrokCredentialsStore { + /// Top-level OIDC scope used by `grok login` for SuperGrok subscribers. + public static let oidcScopePrefix = "https://auth.x.ai::" + /// Legacy/session scope used by older `grok login` flows. + public static let legacySessionScope = "https://accounts.x.ai/sign-in" + + public static func grokHomeURL( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL + { + if let custom = env["GROK_HOME"]?.nilIfEmpty { + return URL(fileURLWithPath: (custom as NSString).expandingTildeInPath) + } + let home = fileManager.homeDirectoryForCurrentUser + return home.appendingPathComponent(".grok", isDirectory: true) + } + + public static func authFileURL( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL + { + self.grokHomeURL(env: env, fileManager: fileManager).appendingPathComponent("auth.json") + } + + public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws -> GrokCredentials { + let url = self.authFileURL(env: env) + guard FileManager.default.fileExists(atPath: url.path) else { + throw GrokCredentialsError.notFound + } + let data = try Data(contentsOf: url) + return try self.parse(data: data) + } + + public static func parse(data: Data) throws -> GrokCredentials { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data) + } catch { + throw GrokCredentialsError.decodeFailed(error.localizedDescription) + } + guard let root = raw as? [String: Any] else { + throw GrokCredentialsError.decodeFailed("Invalid JSON (expected object at root)") + } + + // `auth.json` is a map keyed by scope URL. Prefer the OIDC scope (SuperGrok), + // fall back to the legacy session scope. + let preferredEntry = Self.selectPreferredEntry(in: root) + guard let (scope, entry) = preferredEntry else { + throw GrokCredentialsError.missingTokens + } + guard let key = entry["key"] as? String, !key.isEmpty else { + throw GrokCredentialsError.missingTokens + } + + return GrokCredentials( + accessToken: key, + refreshToken: (entry["refresh_token"] as? String)?.nilIfEmpty, + scope: scope, + authMode: (entry["auth_mode"] as? String)?.nilIfEmpty, + userId: (entry["user_id"] as? String)?.nilIfEmpty, + email: (entry["email"] as? String)?.nilIfEmpty, + firstName: (entry["first_name"] as? String)?.nilIfEmpty, + lastName: (entry["last_name"] as? String)?.nilIfEmpty, + teamId: (entry["team_id"] as? String)?.nilIfEmpty, + oidcIssuer: (entry["oidc_issuer"] as? String)?.nilIfEmpty, + oidcClientId: (entry["oidc_client_id"] as? String)?.nilIfEmpty, + expiresAt: Self.parseDate(entry["expires_at"]), + createTime: Self.parseDate(entry["create_time"])) + } + + private static func selectPreferredEntry(in root: [String: Any]) -> (scope: String, entry: [String: Any])? { + var oidcCandidate: (String, [String: Any])? + var legacyCandidate: (String, [String: Any])? + for (scope, value) in root { + guard let entry = value as? [String: Any] else { continue } + // Only accept entries that actually carry a usable bearer token. A + // stale/partial OIDC record (key missing or empty) must not shadow a + // healthy legacy session entry. + guard let key = entry["key"] as? String, !key.isEmpty else { continue } + if scope.hasPrefix(self.oidcScopePrefix) { + oidcCandidate = (scope, entry) + } else if scope == self.legacySessionScope || scope.contains("/sign-in") { + legacyCandidate = (scope, entry) + } + } + return oidcCandidate ?? legacyCandidate + } + + private static func parseDate(_ raw: Any?) -> Date? { + guard let value = raw as? String, !value.isEmpty else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokCookieImporter.swift b/Sources/CodexBarCore/Providers/Grok/GrokCookieImporter.swift new file mode 100644 index 000000000..2efd4ee21 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokCookieImporter.swift @@ -0,0 +1,213 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum GrokCookieImporter { + private static let importSessionCacheTTL: TimeInterval = 5 + private static let importSessionCache = ImportSessionCache(ttl: importSessionCacheTTL) + private static let log = CodexBarLog.logger(LogCategories.providers) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["grok.com"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.grok]?.browserCookieOrder ?? Browser.defaultImportOrder + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let cached = self.cachedImportSessions() { + return cached + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { throw GrokWebBillingError.missingCredentials } + self.storeImportSessions(sessions) + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard mergedRecords.contains(where: { $0.name == "sso" || $0.name == "sso-rw" }) else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + log("Found Grok session cookies in \(label)") + sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { throw GrokWebBillingError.missingCredentials } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession(browserDetection: browserDetection, logger: logger) + return true + } catch { + return false + } + } + + static func invalidateImportSessionCache() { + self.importSessionCache.invalidate() + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[grok-cookie] \(message)") + self.log.debug("\(message)") + } + + private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? { + self.importSessionCache.load(now: now) + } + + private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) { + self.importSessionCache.store(sessions, now: now) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (sessions: [SessionInfo], expiresAt: Date)? + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + func load(now: Date) -> [SessionInfo]? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entry, entry.expiresAt > now else { + self.entry = nil + return nil + } + return entry.sessions + } + + func store(_ sessions: [SessionInfo], now: Date) { + self.lock.lock() + self.entry = (sessions, now.addingTimeInterval(self.ttl)) + self.lock.unlock() + } + + func invalidate() { + self.lock.lock() + self.entry = nil + self.lock.unlock() + } + } +} +#else +public enum GrokCookieImporter { + public static func hasSession( + browserDetection _: BrowserDetection = BrowserDetection(), + logger _: ((String) -> Void)? = nil) -> Bool + { + false + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift new file mode 100644 index 000000000..c5b1afded --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokLocalSessionScanner.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Aggregated stats from local `~/.grok/sessions/**/signals.json` files. +/// Used as a local fallback view when the JSON-RPC billing call is unavailable. +public struct GrokLocalSessionSummary: Sendable { + public let sessionCount: Int + public let totalTokens: Int + public let lastSessionAt: Date? + public let primaryModel: String? + public let models: [String] + + public init( + sessionCount: Int, + totalTokens: Int, + lastSessionAt: Date?, + primaryModel: String?, + models: [String]) + { + self.sessionCount = sessionCount + self.totalTokens = totalTokens + self.lastSessionAt = lastSessionAt + self.primaryModel = primaryModel + self.models = models + } +} + +public enum GrokLocalSessionScanner { + public static let defaultLookbackDays = 30 + + /// Walk `~/.grok/sessions///signals.json` and aggregate stats. + public static func summarize( + env: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + lookbackDays: Int = defaultLookbackDays, + now: Date = .init()) -> GrokLocalSessionSummary + { + let root = GrokCredentialsStore.grokHomeURL(env: env, fileManager: fileManager) + .appendingPathComponent("sessions", isDirectory: true) + guard let rootEnum = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles]) + else { + return GrokLocalSessionSummary( + sessionCount: 0, + totalTokens: 0, + lastSessionAt: nil, + primaryModel: nil, + models: []) + } + + let lookbackCutoff = Calendar.current.date(byAdding: .day, value: -lookbackDays, to: now) ?? now + var sessionCount = 0 + var totalTokens = 0 + var lastSessionAt: Date? + var modelCounts: [String: Int] = [:] + + while let url = rootEnum.nextObject() as? URL { + guard url.lastPathComponent == "signals.json" else { continue } + let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + let mtime = attrs?.contentModificationDate ?? Date.distantPast + guard mtime >= lookbackCutoff else { continue } + + guard let data = try? Data(contentsOf: url), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + sessionCount += 1 + let beforeCompaction = (json["totalTokensBeforeCompaction"] as? Int) ?? 0 + let contextUsed = (json["contextTokensUsed"] as? Int) ?? 0 + totalTokens += beforeCompaction + contextUsed + + if mtime > (lastSessionAt ?? Date.distantPast) { + lastSessionAt = mtime + } + + if let primary = (json["primaryModelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !primary.isEmpty + { + modelCounts[primary, default: 0] += 1 + } + if let models = json["modelsUsed"] as? [String] { + for model in models { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + modelCounts[trimmed, default: 0] += 1 + } + } + } + } + + let sortedModels = modelCounts.sorted { $0.value > $1.value }.map(\.key) + return GrokLocalSessionSummary( + sessionCount: sessionCount, + totalTokens: totalTokens, + lastSessionAt: lastSessionAt, + primaryModel: sortedModels.first, + models: sortedModels) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift new file mode 100644 index 000000000..f3ed86972 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -0,0 +1,174 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum GrokProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .grok, + metadata: ProviderMetadata( + id: .grok, + displayName: "Grok", + sessionLabel: "Monthly", + weeklyLabel: "On-demand", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Grok usage", + cliName: "grok", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.grokCookieImportOrder, + dashboardURL: "https://grok.com/?_s=usage", + changelogURL: "https://x.ai/news", + statusPageURL: nil, + statusLinkURL: "https://status.x.ai"), + branding: ProviderBranding( + iconStyle: .grok, + iconResourceName: "ProviderIcon-grok", + color: ProviderColor(red: 16 / 255, green: 163 / 255, blue: 127 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Grok cost summary is not supported yet." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "grok", + versionDetector: { _ in GrokStatusProbe.detectVersion() })) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + switch context.sourceMode { + case .auto: + [GrokCLIFetchStrategy(), GrokWebFetchStrategy()] + case .cli: + [GrokCLIFetchStrategy()] + case .web: + [GrokWebFetchStrategy()] + case .api, .oauth: + [] + } + } +} + +struct GrokCLIFetchStrategy: ProviderFetchStrategy { + let id: String = "grok.cli" + let kind: ProviderFetchKind = .cli + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + BinaryLocator.resolveGrokBinary(env: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let probe = GrokStatusProbe() + let snap = try await probe.fetch(env: context.env) + return self.makeResult( + usage: snap.toUsageSnapshot(), + sourceLabel: "grok-cli") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } +} + +struct GrokWebFetchStrategy: ProviderFetchStrategy { + let id: String = "grok.web" + let kind: ProviderFetchKind = .web + + static func canImportBrowserCookies(runtime: ProviderRuntime, env: [String: String]) -> Bool { + runtime == .app || env["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT"] == "1" + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + #if os(macOS) + if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env), + GrokCookieImporter.hasSession(browserDetection: context.browserDetection) + { + return true + } + #endif + return FileManager.default.fileExists(atPath: GrokCredentialsStore.authFileURL(env: context.env).path) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let (webBilling, sourceLabel, authenticatedByAuthFile) = try await self.fetchWebBilling(context: context) + let credentials = Self.credentialsForWebBillingSnapshot( + credentials: try? GrokCredentialsStore.load(env: context.env), + authenticatedByAuthFile: authenticatedByAuthFile) + let snapshot = GrokUsageSnapshot( + billing: nil, + webBilling: webBilling, + credentials: GrokStatusProbe.credentialsForSnapshot( + credentials: credentials, + billing: nil, + webBilling: webBilling), + localSummary: GrokLocalSessionScanner.summarize(env: context.env), + cliVersion: GrokStatusProbe.detectVersion(env: context.env), + updatedAt: Date()) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: sourceLabel) + } + + private func fetchWebBilling(context: ProviderFetchContext) async throws -> ( + snapshot: GrokWebBillingSnapshot, + sourceLabel: String, + authenticatedByAuthFile: Bool) + { + #if os(macOS) + if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env) { + var lastCookieError: Error? + do { + let sessions = try GrokCookieImporter.importSessions(browserDetection: context.browserDetection) + let (snapshot, sourceLabel) = try await Self.fetchFirstValidCookieSession(sessions) + return (snapshot, sourceLabel, false) + } catch { + lastCookieError = error + } + if !FileManager.default.fileExists(atPath: GrokCredentialsStore.authFileURL(env: context.env).path) { + throw lastCookieError ?? GrokWebBillingError.missingCredentials + } + } + #endif + + let credentials = try GrokCredentialsStore.load(env: context.env) + let snapshot = try await GrokWebBillingFetcher.fetch(credentials: credentials) + return (snapshot, "grok-web", true) + } + + static func credentialsForWebBillingSnapshot( + credentials: GrokCredentials?, + authenticatedByAuthFile: Bool) -> GrokCredentials? + { + authenticatedByAuthFile ? credentials : nil + } + + #if os(macOS) + static func fetchFirstValidCookieSession( + _ sessions: [GrokCookieImporter.SessionInfo], + fetch: (String) async throws -> GrokWebBillingSnapshot = { cookieHeader in + try await GrokWebBillingFetcher.fetch(cookieHeader: cookieHeader) + }) async throws -> (GrokWebBillingSnapshot, String) + { + var lastError: Error? + for session in sessions { + do { + let snapshot = try await fetch(session.cookieHeader) + return (snapshot, session.sourceLabel) + } catch { + lastError = error + } + } + throw lastError ?? GrokWebBillingError.missingCredentials + } + #endif + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift new file mode 100644 index 000000000..92238df80 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -0,0 +1,363 @@ +import Foundation + +/// JSON-RPC client for `grok agent stdio` (ACP protocol). +/// +/// The protocol mirrors Codex's app-server (newline-delimited JSON-RPC 2.0 over stdin/stdout), +/// but uses `protocolVersion`/`clientCapabilities` for the `initialize` call instead of +/// `clientInfo`. Billing is fetched via the `x.ai/billing` extension method. +final class GrokRPCClient: @unchecked Sendable { + private static let log = CodexBarLog.logger(LogCategories.grok) + + private let process = Process() + private let stdinPipe = Pipe() + private let stdoutPipe = Pipe() + private let stderrPipe = Pipe() + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval + private var nextID: Int = 1 + private let stdoutLineStream: AsyncStream + private let stdoutLineContinuation: AsyncStream.Continuation + + init( + executable: String = "grok", + arguments: [String] = ["agent", "stdio"], + environment: [String: String] = ProcessInfo.processInfo.environment, + initializeTimeoutSeconds: TimeInterval = 4.0, + requestTimeoutSeconds: TimeInterval = 3.0) throws + { + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds + var stdoutContinuation: AsyncStream.Continuation! + self.stdoutLineStream = AsyncStream { continuation in + stdoutContinuation = continuation + } + self.stdoutLineContinuation = stdoutContinuation + + let resolvedExec = BinaryLocator.resolveGrokBinary(env: environment) + ?? TTYCommandRunner.which(executable) + + guard let resolvedExec else { + Self.log.warning("Grok RPC binary not found", metadata: ["binary": executable]) + throw GrokRPCError.binaryNotFound + } + + var env = environment + env["PATH"] = PathBuilder.effectivePATH(purposes: [.rpc], env: env) + + self.process.environment = env + self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + self.process.arguments = [resolvedExec] + arguments + self.process.standardInput = self.stdinPipe + self.process.standardOutput = self.stdoutPipe + self.process.standardError = self.stderrPipe + + do { + try self.process.run() + Self.log.debug("Grok RPC started", metadata: ["binary": resolvedExec]) + } catch { + Self.log.warning("Grok RPC failed to start", metadata: ["error": error.localizedDescription]) + throw GrokRPCError.startFailed(error.localizedDescription) + } + + let stdoutHandle = self.stdoutPipe.fileHandleForReading + let stdoutLineContinuation = self.stdoutLineContinuation + let stdoutBuffer = LineBuffer() + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + stdoutLineContinuation.finish() + return + } + let lines = stdoutBuffer.appendAndDrainLines(data) + for lineData in lines { + stdoutLineContinuation.yield(lineData) + } + } + + let stderrHandle = self.stderrPipe.fileHandleForReading + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + return + } + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + for line in text.split(whereSeparator: \.isNewline) { + #if !os(Linux) + fputs("[grok stderr] \(line)\n", stderr) + #endif + } + } + } + + deinit { + self.shutdown() + } + + func initialize() async throws { + let params: [String: Any] = [ + "protocolVersion": "1", + "clientCapabilities": [ + "fs": ["readTextFile": false, "writeTextFile": false], + "terminal": false, + ], + ] + _ = try await self.request( + method: "initialize", + params: params, + timeout: self.initializeTimeoutSeconds) + } + + /// Calls `x.ai/billing` and returns the decoded response. + func fetchBilling() async throws -> GrokBillingResponse { + let message = try await self.request(method: "x.ai/billing", params: [:]) + return try self.decodeResult(from: message) + } + + func shutdown() { + if self.process.isRunning { + Self.log.debug("Grok RPC stopping") + self.process.terminate() + } + } + + // MARK: - JSON-RPC plumbing (mirrors CodexRPCClient) + + private struct SendableJSONMessage: @unchecked Sendable { + let value: [String: Any] + } + + private func request( + method: String, + params: [String: Any]? = nil, + timeout: TimeInterval? = nil) async throws -> [String: Any] + { + let id = self.nextID + self.nextID += 1 + try self.sendRequest(id: id, method: method, params: params) + + let resolvedTimeout = timeout ?? self.requestTimeoutSeconds + let wrapped = try await self.withTimeout(seconds: resolvedTimeout, method: method) { + while true { + let message = try await self.readNextMessage() + // Skip notifications (no id) or unrelated responses. + if message["id"] == nil { continue } + guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + if let error = message["error"] as? [String: Any] { + let messageText = (error["message"] as? String) ?? "unknown JSON-RPC error" + throw GrokRPCError.requestFailed(messageText) + } + return SendableJSONMessage(value: message) + } + } + return wrapped.value + } + + private func withTimeout( + seconds: TimeInterval, + method: String, + body: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await body() } + group.addTask { [weak self] in + try await Task.sleep(for: .seconds(seconds)) + self?.terminateProcessForTimeout(method: method) + throw GrokRPCError.timeout(method: method) + } + do { + guard let result = try await group.next() else { + throw GrokRPCError.timeout(method: method) + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error + } + } + } + + private func terminateProcessForTimeout(method: String) { + if self.process.isRunning { + Self.log.warning("Grok RPC timed out on `\(method)`; terminating process") + self.process.terminate() + } + } + + private func sendRequest(id: Int, method: String, params: [String: Any]?) throws { + let paramsValue: Any = params ?? [:] + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": paramsValue, + ] + try self.sendPayload(payload) + } + + private func sendPayload(_ payload: [String: Any]) throws { + let raw = try JSONSerialization.data(withJSONObject: payload) + // Foundation's JSONSerialization escapes "/" as "\/" by default. Grok's + // ACP server treats the escaped form as a *different* method name (it does + // not unescape before lookup), so `x.ai/billing` becomes "Method not found" + // when sent as `x.ai\/billing`. Re-encode without slash escapes to match + // the on-the-wire shape the grok agent expects. + let unescaped = String(data: raw, encoding: .utf8)? + .replacingOccurrences(of: "\\/", with: "/") + let data = unescaped.flatMap { $0.data(using: .utf8) } ?? raw + if let preview = String(data: data.prefix(200), encoding: .utf8) { + Self.log.debug("grok rpc -> \(preview)") + } + self.stdinPipe.fileHandleForWriting.write(data) + self.stdinPipe.fileHandleForWriting.write(Data([0x0A])) + } + + private func readNextMessage() async throws -> [String: Any] { + for await lineData in self.stdoutLineStream { + if lineData.isEmpty { continue } + if let preview = String(data: lineData.prefix(300), encoding: .utf8) { + Self.log.debug("grok rpc <- \(preview)") + } + if let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] { + return json + } + } + throw GrokRPCError.malformed("grok agent stdio closed stdout") + } + + private func decodeResult(from message: [String: Any]) throws -> T { + guard let result = message["result"] else { + throw GrokRPCError.malformed("missing result field") + } + let data = try JSONSerialization.data(withJSONObject: result) + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } + + private func jsonID(_ value: Any?) -> Int? { + switch value { + case let int as Int: int + case let number as NSNumber: number.intValue + default: nil + } + } + + private final class LineBuffer: @unchecked Sendable { + private var buffer = Data() + private let lock = NSLock() + + func appendAndDrainLines(_ data: Data) -> [Data] { + self.lock.lock() + defer { lock.unlock() } + self.buffer.append(data) + var out: [Data] = [] + while let newline = self.buffer.firstIndex(of: 0x0A) { + let lineData = Data(self.buffer[.. }` in the wire format. +public struct GrokBillingResponse: Codable, Sendable { + public let billingCycle: GrokBillingCycle? + public let monthlyLimit: GrokCent? + public let onDemandCap: GrokCent? + public let onDemandEnabled: Bool? + public let disabledByConfig: Bool? + public let usage: GrokBillingUsage? + + private enum CodingKeys: String, CodingKey { + case billingCycle + case monthlyLimit + case onDemandCap + case onDemandEnabled = "on_demand_enabled" + case disabledByConfig + case usage + } +} + +public struct GrokBillingCycle: Codable, Sendable { + public let billingPeriodStart: String? + public let billingPeriodEnd: String? +} + +public struct GrokBillingUsage: Codable, Sendable { + public let includedUsed: GrokCent? + public let onDemandUsed: GrokCent? + public let totalUsed: GrokCent? +} + +public struct GrokCent: Codable, Sendable { + public let val: Int? +} + +extension GrokBillingResponse { + /// Convenience accessor: monthly usage as a 0–100 percent. + public var monthlyUsedPercent: Double? { + guard let limit = self.monthlyLimit?.val, limit > 0, + let used = self.usage?.totalUsed?.val + else { return nil } + return min(100.0, max(0.0, Double(used) / Double(limit) * 100.0)) + } + + public var billingPeriodEndDate: Date? { + guard let raw = self.billingCycle?.billingPeriodEnd else { return nil } + return GrokBillingResponse.parseISO8601(raw) + } + + public var billingPeriodStartDate: Date? { + guard let raw = self.billingCycle?.billingPeriodStart else { return nil } + return GrokBillingResponse.parseISO8601(raw) + } + + private static func parseISO8601(_ raw: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: raw) + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift new file mode 100644 index 000000000..9a82eb6b6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -0,0 +1,157 @@ +import Foundation + +public struct GrokUsageSnapshot: Sendable { + public let billing: GrokBillingResponse? + public let webBilling: GrokWebBillingSnapshot? + public let credentials: GrokCredentials? + public let localSummary: GrokLocalSessionSummary? + public let cliVersion: String? + public let updatedAt: Date + + public init( + billing: GrokBillingResponse?, + webBilling: GrokWebBillingSnapshot? = nil, + credentials: GrokCredentials?, + localSummary: GrokLocalSessionSummary?, + cliVersion: String?, + updatedAt: Date) + { + self.billing = billing + self.webBilling = webBilling + self.credentials = credentials + self.localSummary = localSummary + self.cliVersion = cliVersion + self.updatedAt = updatedAt + } + + 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. + var primary: RateWindow? + if let billing, + let percent = billing.monthlyUsedPercent + { + primary = RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: billing.billingPeriodEndDate, + resetDescription: nil) + } else if let webBilling, + let percent = webBilling.usedPercent + { + primary = RateWindow( + usedPercent: percent, + windowMinutes: nil, + resetsAt: webBilling.resetsAt, + resetDescription: nil) + } + + let identity = ProviderIdentitySnapshot( + providerID: .grok, + accountEmail: self.credentials?.email, + accountOrganization: self.credentials?.teamId, + loginMethod: self.credentials?.loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public struct GrokStatusProbe: Sendable { + public init() {} + + public static func detectVersion(env: [String: String] = ProcessInfo.processInfo.environment) -> String? { + guard let binary = BinaryLocator.resolveGrokBinary(env: env) else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [binary, "--version"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + // Output is like "grok 0.1.210 (8b63e9068c)" — strip the leading "grok " so + // callers can prefix the CLI name themselves without duplicating it. + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + let firstLine = trimmed.split(separator: "\n").first.map(String.init) ?? trimmed + let withoutPrefix = firstLine.replacingOccurrences( + of: #"^grok\s+"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return withoutPrefix.isEmpty ? nil : withoutPrefix + } catch { + return nil + } + } + + public func fetch(env: [String: String] = ProcessInfo.processInfo.environment) async throws -> GrokUsageSnapshot { + // Credentials are optional: we still show identity-less state if the user + // hasn't logged in, with a clear hint via the RPC error. + let credentials = try? GrokCredentialsStore.load(env: env) + + var billing: GrokBillingResponse? + var rpcError: Error? + do { + let client = try GrokRPCClient(environment: env) + defer { client.shutdown() } + try await client.initialize() + billing = try await client.fetchBilling() + } catch { + rpcError = error + } + + // Local fallback summary always succeeds (empty if no sessions yet). + let localSummary = GrokLocalSessionScanner.summarize(env: env) + let cliVersion = Self.detectVersion(env: env) + + // `localSummary` is *not* currently projected into a visible RateWindow or + // identity field, so a stale `~/.grok/sessions/` directory must not + // suppress the auth-required hint. CLI-only fetches need a billing + // response; the provider pipeline owns the separate web fallback. + if billing == nil { + throw rpcError ?? GrokRPCError.notAuthenticated + } + + return GrokUsageSnapshot( + billing: billing, + webBilling: nil, + credentials: Self.credentialsForSnapshot( + credentials: credentials, + billing: billing, + webBilling: nil), + localSummary: localSummary, + cliVersion: cliVersion, + updatedAt: Date()) + } + + static func credentialsForSnapshot( + credentials: GrokCredentials?, + billing: GrokBillingResponse?, + webBilling: GrokWebBillingSnapshot? = nil) -> GrokCredentials? + { + // If remote usage succeeded, xAI accepted auth and the local + // identity is still useful even when the persisted expires_at is stale. + if billing != nil || webBilling != nil { return credentials } + return credentials.flatMap { $0.isExpired ? nil : $0 } + } + + static func shouldSurfaceRemoteAuthError(_ error: Error?) -> Bool { + guard let error = error as? GrokWebBillingError else { return false } + switch error { + case let .requestFailed(status, _): + return status == 401 || status == 403 + case let .rpcFailed(status, _): + return status == 16 + case .missingCredentials, .emptyResponse, .invalidResponse, .parseFailed: + return false + } + } +} diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift new file mode 100644 index 000000000..50a0beae3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -0,0 +1,386 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct GrokWebBillingSnapshot: Sendable, Equatable { + public let usedPercent: Double? + public let resetsAt: Date? + + public init(usedPercent: Double?, resetsAt: Date?) { + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +public enum GrokWebBillingError: LocalizedError, Sendable { + case missingCredentials + case emptyResponse + case invalidResponse + case requestFailed(Int, String) + case rpcFailed(Int, String) + case parseFailed + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Grok web billing requires a signed-in grok.com browser session or `grok login`." + case .emptyResponse: + "Grok web billing returned no protobuf payload." + case .invalidResponse: + "Grok web billing returned an invalid response." + case let .requestFailed(status, body): + if status == 401 || status == 403 { + "Grok web billing rejected credentials. Run `grok login` to refresh xAI auth." + } else { + "Grok web billing request failed with HTTP \(status): \(body)" + } + case let .rpcFailed(status, message): + if status == 16 { + "Grok web billing rejected credentials. Run `grok login` to refresh xAI auth." + } else { + "Grok web billing RPC failed with status \(status): \(message)" + } + case .parseFailed: + "Could not parse Grok web billing usage." + } + } +} + +public enum GrokWebBillingFetcher { + public static let defaultEndpoint = + URL(string: "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")! + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetch( + credentials: GrokCredentials, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + endpoint: URL = Self.defaultEndpoint) async throws -> GrokWebBillingSnapshot + { + try await self.fetch( + authorizationHeader: "Bearer \(credentials.accessToken)", + cookieHeader: nil, + transport: transport, + endpoint: endpoint) + } + + public static func fetch( + cookieHeader: String, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + endpoint: URL = Self.defaultEndpoint) async throws -> GrokWebBillingSnapshot + { + try await self.fetch( + authorizationHeader: nil, + cookieHeader: cookieHeader, + transport: transport, + endpoint: endpoint) + } + + private static func fetch( + authorizationHeader: String?, + cookieHeader: String?, + transport: any ProviderHTTPTransport, + endpoint: URL) async throws -> GrokWebBillingSnapshot + { + do { + return try await self.fetchOnce( + authorizationHeader: authorizationHeader, + cookieHeader: cookieHeader, + transport: transport, + endpoint: endpoint) + } catch where self.shouldRetry(error) { + return try await self.fetchOnce( + authorizationHeader: authorizationHeader, + cookieHeader: cookieHeader, + transport: transport, + endpoint: endpoint) + } + } + + private static func fetchOnce( + authorizationHeader: String?, + cookieHeader: String?, + transport: any ProviderHTTPTransport, + endpoint: URL) async throws -> GrokWebBillingSnapshot + { + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = Self.requestTimeoutSeconds + request.httpBody = Data([0x00, 0x00, 0x00, 0x00, 0x00]) + if let authorizationHeader { + request.setValue(authorizationHeader, forHTTPHeaderField: "Authorization") + } + if let cookieHeader { + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + } + request.setValue("https://grok.com", forHTTPHeaderField: "Origin") + request.setValue("https://grok.com/?_s=usage", forHTTPHeaderField: "Referer") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("application/grpc-web+proto", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "x-grpc-web") + request.setValue("connect-es/2.1.1", forHTTPHeaderField: "x-user-agent") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw GrokWebBillingError.invalidResponse + } catch { + throw error + } + guard response.statusCode == 200 else { + let body = String(data: response.data.prefix(400), encoding: .utf8) ?? "" + throw GrokWebBillingError.requestFailed(response.statusCode, body) + } + try Self.validateGRPCStatusFields(Self.grpcHeaderFields(from: response.response.allHeaderFields)) + try Self.validateGRPCWebTrailers(response.data) + + return try Self.parseGRPCWebResponse(response.data) + } + + private static func shouldRetry(_ error: Error) -> Bool { + if let urlError = error as? URLError { + return urlError.code == .timedOut || urlError.code == .networkConnectionLost + } + if case let GrokWebBillingError.requestFailed(status, body) = error { + if [408, 502, 503, 504].contains(status) { return true } + return body.localizedCaseInsensitiveContains("timeout") + || body.localizedCaseInsensitiveContains("deadline") + } + guard case let GrokWebBillingError.rpcFailed(status, message) = error else { return false } + if status == 4 { return true } + guard status == 1 else { return false } + return message.localizedCaseInsensitiveContains("timeout") + || message.localizedCaseInsensitiveContains("deadline") + || message.localizedCaseInsensitiveContains("expired") + } + + static func parseGRPCWebResponse(_ data: Data, now: Date = Date()) throws -> GrokWebBillingSnapshot { + let payloads = Self.grpcWebDataFrames(from: data) + guard !payloads.isEmpty else { throw GrokWebBillingError.emptyResponse } + + var scan = ProtobufScan() + for payload in payloads { + scan.merge(Self.scanProtobuf(payload, depth: 0)) + } + + let parsedPercent = scan.fixed32Fields + .filter { field in + field.path.last == 1 && field.value.isFinite && field.value >= 0 && field.value <= 100 + } + .min { lhs, rhs in + lhs.path.count == rhs.path.count ? lhs.order < rhs.order : lhs.path.count < rhs.path.count + } + .map { Double($0.value) } + + let resetFields = scan.varintFields.compactMap { field -> (path: [UInt64], date: Date)? in + let raw = field.value + guard raw >= 1_700_000_000, raw <= 2_100_000_000 else { return nil } + return (field.path, Date(timeIntervalSince1970: TimeInterval(raw))) + } + let futureResetFields = resetFields.filter { $0.date > now } + let reset = futureResetFields + .filter { $0.path == [1, 5, 1] } + .map(\.date) + .min() ?? futureResetFields + .map(\.date) + .min() + + let noUsageYet = parsedPercent == nil && + scan.fixed32Fields.isEmpty && + reset != nil && + scan.varintFields.contains { $0.path.starts(with: [1, 6]) } + guard let percent = parsedPercent ?? (noUsageYet ? 0 : nil) else { + throw GrokWebBillingError.parseFailed + } + return GrokWebBillingSnapshot(usedPercent: percent, resetsAt: reset) + } + + static func grpcWebDataFrames(from data: Data) -> [Data] { + let bytes = [UInt8](data) + var frames: [Data] = [] + var index = 0 + while index + 5 <= bytes.count { + let flags = bytes[index] + let length = (Int(bytes[index + 1]) << 24) + | (Int(bytes[index + 2]) << 16) + | (Int(bytes[index + 3]) << 8) + | Int(bytes[index + 4]) + let start = index + 5 + let end = start + length + guard length >= 0, end <= bytes.count else { break } + if flags & 0x80 == 0 { + frames.append(Data(bytes[start.. [String: String] { + var fields: [String: String] = [:] + for (key, value) in headers { + let normalizedKey = String(describing: key) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard normalizedKey.hasPrefix("grpc-") else { continue } + fields[normalizedKey] = String(describing: value) + .trimmingCharacters(in: .whitespacesAndNewlines) + .removingPercentEncoding ?? "" + } + return fields + } + + static func grpcWebTrailerFields(from data: Data) -> [String: String] { + let bytes = [UInt8](data) + var fields: [String: String] = [:] + var index = 0 + while index + 5 <= bytes.count { + let flags = bytes[index] + let length = (Int(bytes[index + 1]) << 24) + | (Int(bytes[index + 2]) << 16) + | (Int(bytes[index + 3]) << 8) + | Int(bytes[index + 4]) + let start = index + 5 + let end = start + length + guard length >= 0, end <= bytes.count else { break } + if flags & 0x80 != 0, let text = String(data: Data(bytes[start.. ProtobufScan { + self.scanProtobuf(data, depth: depth, path: [], order: 0).scan + } + + private static func scanProtobuf( + _ data: Data, + depth: Int, + path: [UInt64], + order: Int) -> (scan: ProtobufScan, order: Int) + { + let bytes = [UInt8](data) + var scan = ProtobufScan() + var index = 0 + var nextOrder = order + + while index < bytes.count { + let fieldStart = index + guard let key = Self.readVarint(bytes, index: &index), key != 0 else { + index = fieldStart + 1 + continue + } + let fieldNumber = key >> 3 + let wireType = key & 0x07 + let fieldPath = path + [fieldNumber] + + switch wireType { + case 0: + if let value = Self.readVarint(bytes, index: &index) { + scan.varintFields.append(ProtobufScan.VarintField(path: fieldPath, value: value)) + } else { + index = fieldStart + 1 + } + case 1: + guard index + 8 <= bytes.count else { return (scan, nextOrder) } + index += 8 + case 2: + guard let length = Self.readVarint(bytes, index: &index), + length <= UInt64(bytes.count - index) + else { + index = fieldStart + 1 + continue + } + let start = index + let end = index + Int(length) + if depth < 4 { + let nested = Self.scanProtobuf( + Data(bytes[start.. UInt64? { + var value: UInt64 = 0 + var shift: UInt64 = 0 + while index < bytes.count, shift < 64 { + let byte = bytes[index] + index += 1 + value |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return value } + shift += 7 + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloBearerTokenResolver.swift b/Sources/CodexBarCore/Providers/Kilo/KiloBearerTokenResolver.swift new file mode 100644 index 000000000..6478c2031 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloBearerTokenResolver.swift @@ -0,0 +1,78 @@ +import Foundation + +public struct KiloResolvedBearerToken: Sendable, Equatable { + public let token: String + public let sourceLabel: String + + public init(token: String, sourceLabel: String) { + self.token = token + self.sourceLabel = sourceLabel + } +} + +/// Resolves the Kilo bearer token shared by usage fetches and organization discovery. +/// +/// Behavior mirrors the per-strategy resolution in `KiloProviderDescriptor`: +/// - `.api`: explicit `apiKey`, falling back to `KILO_API_KEY` env var. +/// - `.cli`: token from `~/.local/share/kilo/auth.json` (`kilo.access`). +/// - `.auto`: API first, falling back to CLI when API credentials are missing. +public enum KiloBearerTokenResolver { + public static func resolve( + source: KiloUsageDataSource, + apiKey: String?, + environment: [String: String] = ProcessInfo.processInfo.environment) throws -> KiloResolvedBearerToken + { + switch source { + case .api: + return try self.resolveAPI(apiKey: apiKey, environment: environment) + case .cli: + return try self.resolveCLI(environment: environment) + case .auto: + if let resolved = try? self.resolveAPI(apiKey: apiKey, environment: environment) { + return resolved + } + return try self.resolveCLI(environment: environment) + } + } + + private static func resolveAPI( + apiKey: String?, + environment: [String: String]) throws -> KiloResolvedBearerToken + { + let direct = KiloSettingsReader.cleaned(apiKey) + let envValue = KiloSettingsReader.cleaned(environment[KiloSettingsReader.apiTokenKey]) + if let token = direct ?? envValue { + return KiloResolvedBearerToken(token: token, sourceLabel: "api") + } + throw KiloUsageError.missingCredentials + } + + private static func resolveCLI( + environment: [String: String]) throws -> KiloResolvedBearerToken + { + let url = self.authFileURL(environment: environment) + guard FileManager.default.fileExists(atPath: url.path) else { + throw KiloUsageError.cliSessionMissing(url.path) + } + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw KiloUsageError.cliSessionUnreadable(url.path) + } + guard let token = KiloSettingsReader.parseAuthToken(data: data) else { + throw KiloUsageError.cliSessionInvalid(url.path) + } + return KiloResolvedBearerToken(token: token, sourceLabel: "cli") + } + + static func authFileURL(environment: [String: String]) -> URL { + if let home = KiloSettingsReader.cleaned(environment["HOME"]) { + let expandedHome = NSString(string: home).expandingTildeInPath + return KiloSettingsReader.defaultAuthFileURL( + homeDirectory: URL(fileURLWithPath: expandedHome, isDirectory: true)) + } + return KiloSettingsReader.defaultAuthFileURL( + homeDirectory: FileManager.default.homeDirectoryForCurrentUser) + } +} diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift b/Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift new file mode 100644 index 000000000..46b43fad6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct KiloOrganization: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let name: String + public let role: String? + + public init(id: String, name: String, role: String? = nil) { + self.id = id + self.name = name + self.role = role + } +} diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift index a274b113a..59135ff8f 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift @@ -234,6 +234,7 @@ public enum KiloUsageError: LocalizedError, Sendable, Equatable { } } +// swiftlint:disable:next type_body_length public struct KiloUsageFetcher: Sendable { private struct KiloPassFields { let used: Double? @@ -257,6 +258,7 @@ public struct KiloUsageFetcher: Sendable { public static func fetchUsage( apiKey: String, + scope: KiloUsageScope = .personal, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> KiloUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -264,39 +266,186 @@ public struct KiloUsageFetcher: Sendable { } let baseURL = KiloSettingsReader.apiURL(environment: environment) - let batchURL = try self.makeBatchURL(baseURL: baseURL) + let request = try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + + let response: ProviderHTTPResponse + do { + response = try await ProviderHTTPClient.shared.response(for: request) + } catch { + throw KiloUsageError.networkError(error.localizedDescription) + } + + if let mapped = self.statusError(for: response.statusCode) { + throw mapped + } + + guard response.statusCode == 200 else { + throw KiloUsageError.apiError(response.statusCode) + } + + return try self.parseSnapshot(data: response.data) + } + + static func _buildBatchURLForTesting(baseURL: URL) throws -> URL { + try self.makeBatchURL(baseURL: baseURL) + } + static func _buildRequestForTesting( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + } + + private static func makeRequest( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + let batchURL = try self.makeBatchURL(baseURL: baseURL) var request = URLRequest(url: batchURL) request.httpMethod = "GET" request.timeoutInterval = 15 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") + if let orgId = scope.organizationID { + request.setValue(orgId, forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") + } + return request + } + + public static func fetchOrganizations( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> [KiloOrganization] + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KiloUsageError.missingCredentials + } + + let baseURL = KiloSettingsReader.apiURL(environment: environment) + let trpcRequest = try self.makeOrgListTRPCRequest(baseURL: baseURL, apiKey: apiKey) - let data: Data - let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: request) + let response = try await ProviderHTTPClient.shared.response(for: trpcRequest) + if response.statusCode == 404 { + return try await self.fetchOrganizationsRESTFallback(apiKey: apiKey) + } + if let mapped = self.statusError(for: response.statusCode) { + throw mapped + } + return try self.parseOrganizations(data: response.data) + } catch let error as KiloUsageError { + throw error } catch { throw KiloUsageError.networkError(error.localizedDescription) } + } + + static func _parseOrganizationsForTesting(_ data: Data) throws -> [KiloOrganization] { + try self.parseOrganizations(data: data) + } - guard let httpResponse = response as? HTTPURLResponse else { - throw KiloUsageError.networkError("Invalid response") + private static func makeOrgListTRPCRequest( + baseURL: URL, + apiKey: String) throws -> URLRequest + { + let endpoint = baseURL.appendingPathComponent("user.getOrganizations") + let inputData = try JSONSerialization.data( + withJSONObject: ["0": ["json": NSNull()]] as [String: Any]) + guard let inputString = String(data: inputData, encoding: .utf8), + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) + else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") + } + components.queryItems = [ + URLQueryItem(name: "batch", value: "1"), + URLQueryItem(name: "input", value: inputString), + ] + guard let url = components.url else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private static func fetchOrganizationsRESTFallback(apiKey: String) async throws -> [KiloOrganization] { + guard let url = URL(string: "https://api.kilo.ai/api/profile") else { + throw KiloUsageError.parseFailed("Invalid REST fallback URL") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") - if let mapped = self.statusError(for: httpResponse.statusCode) { + let response = try await ProviderHTTPClient.shared.response(for: request) + if let mapped = self.statusError(for: response.statusCode) { throw mapped } + guard response.statusCode == 200 else { + throw KiloUsageError.apiError(response.statusCode) + } + return try self.parseOrganizations(data: response.data) + } + + private static func parseOrganizations(data: Data) throws -> [KiloOrganization] { + guard let root = try? JSONSerialization.jsonObject(with: data) else { + throw KiloUsageError.parseFailed("Invalid JSON") + } - guard httpResponse.statusCode == 200 else { - throw KiloUsageError.apiError(httpResponse.statusCode) + // tRPC batch shape: [ { result: { data: { json: [orgs] } } } ] + if let entries = root as? [[String: Any]], + let first = entries.first, + let resultObject = first["result"] as? [String: Any] + { + if let dataObject = resultObject["data"] as? [String: Any], + let payload = dataObject["json"] as? [[String: Any]] + { + return self.decodeOrganizations(payload) + } + if let payload = resultObject["data"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } } - return try self.parseSnapshot(data: data) + // REST profile shape: { user: ..., organizations: [orgs] } + if let dictionary = root as? [String: Any] { + if let orgs = dictionary["organizations"] as? [[String: Any]] { + return self.decodeOrganizations(orgs) + } + // Some single-procedure tRPC shapes flatten to { result: { data: { json: { organizations: [...] }}}} + if let resultObject = dictionary["result"] as? [String: Any], + let dataObject = resultObject["data"] as? [String: Any] + { + if let payload = dataObject["json"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } + if let payload = dataObject["json"] as? [String: Any], + let orgs = payload["organizations"] as? [[String: Any]] + { + return self.decodeOrganizations(orgs) + } + } + } + + return [] } - static func _buildBatchURLForTesting(baseURL: URL) throws -> URL { - try self.makeBatchURL(baseURL: baseURL) + private static func decodeOrganizations(_ raw: [[String: Any]]) -> [KiloOrganization] { + raw.compactMap { item -> KiloOrganization? in + guard let id = item["id"] as? String, !id.isEmpty else { return nil } + let name = (item["name"] as? String).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? id + let role = (item["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRole = (role?.isEmpty ?? true) ? nil : role + return KiloOrganization(id: id, name: name.isEmpty ? id : name, role: normalizedRole) + } } static func _parseSnapshotForTesting(_ data: Data) throws -> KiloUsageSnapshot { diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift b/Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift new file mode 100644 index 000000000..30369afef --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum KiloUsageScope: Sendable, Hashable, Equatable { + case personal + case organization(id: String, name: String) + + public var scopeIdentifier: String { + switch self { + case .personal: + "personal" + case let .organization(id, _): + "org:\(id)" + } + } + + public var organizationID: String? { + switch self { + case .personal: + nil + case let .organization(id, _): + id + } + } + + public var displayName: String { + switch self { + case .personal: + "Personal" + case let .organization(_, name): + name + } + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index c6d6c2a2e..1e069123e 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -46,25 +46,22 @@ public struct KimiUsageFetcher: Sendable { let requestBody = ["scope": ["FEATURE_CODING"]] request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw KimiAPIError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let responseBody = String(data: data, encoding: .utf8) ?? "" - Self.log.error("Kimi API returned \(httpResponse.statusCode): \(responseBody)") + Self.log.error("Kimi API returned \(response.statusCode): \(responseBody)") - if httpResponse.statusCode == 401 { + if response.statusCode == 401 { throw KimiAPIError.invalidToken } - if httpResponse.statusCode == 403 { + if response.statusCode == 403 { throw KimiAPIError.invalidToken } - if httpResponse.statusCode == 400 { + if response.statusCode == 400 { throw KimiAPIError.invalidRequest("Bad request") } - throw KimiAPIError.apiError("HTTP \(httpResponse.statusCode)") + throw KimiAPIError.apiError("HTTP \(response.statusCode)") } let usageResponse = try JSONDecoder().decode(KimiUsageResponse.self, from: data) diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift index cc986b6f0..f6903c217 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2ProviderDescriptor.swift @@ -9,20 +9,20 @@ public enum KimiK2ProviderDescriptor { id: .kimik2, metadata: ProviderMetadata( id: .kimik2, - displayName: "Kimi K2", + displayName: "Kimi K2 (unofficial)", sessionLabel: "Credits", weeklyLabel: "Credits", opusLabel: nil, supportsOpus: false, supportsCredits: false, creditsHint: "", - toggleTitle: "Show Kimi K2 usage", + toggleTitle: "Show unofficial Kimi K2 usage", cliName: "kimik2", defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, browserCookieOrder: nil, - dashboardURL: "https://kimi-k2.ai/my-credits", + dashboardURL: nil, statusPageURL: nil), branding: ProviderBranding( iconStyle: .kimi, @@ -30,7 +30,7 @@ public enum KimiK2ProviderDescriptor { color: ProviderColor(red: 76 / 255, green: 0 / 255, blue: 255 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Kimi K2 cost summary is not available." }), + noDataMessage: { "Unofficial Kimi K2 cost summary is not available." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [KimiK2APIFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift index 775e109bf..22f9255e6 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift @@ -29,26 +29,13 @@ public struct KimiK2UsageSummary: Sendable { } public func toUsageSnapshot() -> UsageSnapshot { - let total = max(0, self.consumed + self.remaining) - let usedPercent: Double = if total > 0 { - min(100, max(0, (self.consumed / total) * 100)) - } else { - 0 - } - let usedText = String(format: "%.0f", self.consumed) - let totalText = String(format: "%.0f", total) - let rateWindow = RateWindow( - usedPercent: usedPercent, - windowMinutes: nil, - resetsAt: nil, - resetDescription: total > 0 ? "Credits: \(usedText)/\(totalText)" : nil) let identity = ProviderIdentitySnapshot( providerID: .kimik2, accountEmail: nil, accountOrganization: nil, - loginMethod: nil) + loginMethod: "Credits: \(UsageFormatter.creditsString(from: self.remaining))") return UsageSnapshot( - primary: rateWindow, + primary: nil, secondary: nil, tertiary: nil, providerCost: nil, @@ -136,15 +123,11 @@ public struct KimiK2UsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw KimiK2UsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" - Self.log.error("Kimi K2 API returned \(httpResponse.statusCode): \(body)") + 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) ?? "HTTP \(response.statusCode)" + Self.log.error("Kimi K2 API returned \(response.statusCode): \(body)") throw KimiK2UsageError.apiError(body) } @@ -152,7 +135,7 @@ public struct KimiK2UsageFetcher: Sendable { Self.log.debug("Kimi K2 API response: \(jsonString)") } - let summary = try Self.parseSummary(data: data, headers: httpResponse.allHeaderFields) + let summary = try Self.parseSummary(data: data, headers: response.response.allHeaderFields) return KimiK2UsageSnapshot(summary: summary) } diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 285528485..5d83a1cf7 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -2,15 +2,61 @@ import Foundation public struct KiroUsageSnapshot: Sendable { public let planName: String + public let displayPlanName: String + public let accountEmail: String? + public let authMethod: String? public let creditsUsed: Double public let creditsTotal: Double public let creditsPercent: Double public let bonusCreditsUsed: Double? public let bonusCreditsTotal: Double? public let bonusExpiryDays: Int? + public let overagesStatus: String? + public let overageCreditsUsed: Double? + public let estimatedOverageCostUSD: Double? + public let manageURL: String? + public let contextUsage: KiroContextUsageSnapshot? public let resetsAt: Date? public let updatedAt: Date + public init( + planName: String, + displayPlanName: String? = nil, + accountEmail: String? = nil, + authMethod: String? = nil, + creditsUsed: Double, + creditsTotal: Double, + creditsPercent: Double, + bonusCreditsUsed: Double?, + bonusCreditsTotal: Double?, + bonusExpiryDays: Int?, + overagesStatus: String? = nil, + overageCreditsUsed: Double? = nil, + estimatedOverageCostUSD: Double? = nil, + manageURL: String? = nil, + contextUsage: KiroContextUsageSnapshot? = nil, + resetsAt: Date?, + updatedAt: Date) + { + self.planName = planName + self.displayPlanName = displayPlanName ?? KiroStatusProbe.displayPlanName(planName) + self.accountEmail = accountEmail + self.authMethod = authMethod + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.creditsPercent = creditsPercent + self.bonusCreditsUsed = bonusCreditsUsed + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusExpiryDays = bonusExpiryDays + self.overagesStatus = overagesStatus + self.overageCreditsUsed = overageCreditsUsed + self.estimatedOverageCostUSD = estimatedOverageCostUSD + self.manageURL = manageURL + self.contextUsage = contextUsage + self.resetsAt = resetsAt + self.updatedAt = updatedAt + } + public func toUsageSnapshot() -> UsageSnapshot { let primary = RateWindow( usedPercent: self.creditsPercent, @@ -37,19 +83,116 @@ public struct KiroUsageSnapshot: Sendable { let identity = ProviderIdentitySnapshot( providerID: .kiro, - accountEmail: nil, - accountOrganization: self.planName, - loginMethod: self.planName) + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: self.authMethod) + + let kiroUsage = KiroUsageDetails( + planName: self.planName, + displayPlanName: self.displayPlanName, + creditsUsed: self.creditsUsed, + creditsTotal: self.creditsTotal, + creditsRemaining: self.creditsRemaining, + bonusCreditsUsed: self.bonusCreditsUsed, + bonusCreditsTotal: self.bonusCreditsTotal, + bonusCreditsRemaining: self.bonusCreditsRemaining, + bonusExpiryDays: self.bonusExpiryDays, + overagesStatus: self.overagesStatus, + overageCreditsUsed: self.overageCreditsUsed, + estimatedOverageCostUSD: self.estimatedOverageCostUSD, + manageURL: self.manageURL, + contextUsage: self.contextUsage) return UsageSnapshot( primary: primary, secondary: secondary, tertiary: nil, + kiroUsage: kiroUsage, providerCost: nil, zaiUsage: nil, updatedAt: self.updatedAt, identity: identity) } + + public var creditsRemaining: Double { + max(0, self.creditsTotal - self.creditsUsed) + } + + public var bonusCreditsRemaining: Double? { + guard let bonusCreditsUsed, let bonusCreditsTotal else { return nil } + return max(0, bonusCreditsTotal - bonusCreditsUsed) + } +} + +public struct KiroContextUsageSnapshot: Codable, Equatable, Sendable { + public let totalPercentUsed: Double + public let contextFilesPercent: Double? + public let toolsPercent: Double? + public let kiroResponsesPercent: Double? + public let promptsPercent: Double? + + public init( + totalPercentUsed: Double, + contextFilesPercent: Double?, + toolsPercent: Double?, + kiroResponsesPercent: Double?, + promptsPercent: Double?) + { + self.totalPercentUsed = totalPercentUsed + self.contextFilesPercent = contextFilesPercent + self.toolsPercent = toolsPercent + self.kiroResponsesPercent = kiroResponsesPercent + self.promptsPercent = promptsPercent + } +} + +public struct KiroUsageDetails: Codable, Equatable, Sendable { + public let planName: String + public let displayPlanName: String + public let creditsUsed: Double + public let creditsTotal: Double + public let creditsRemaining: Double + public let bonusCreditsUsed: Double? + public let bonusCreditsTotal: Double? + public let bonusCreditsRemaining: Double? + public let bonusExpiryDays: Int? + public let overagesStatus: String? + public let overageCreditsUsed: Double? + public let estimatedOverageCostUSD: Double? + public let manageURL: String? + public let contextUsage: KiroContextUsageSnapshot? + + public init( + planName: String, + displayPlanName: String, + creditsUsed: Double, + creditsTotal: Double, + creditsRemaining: Double, + bonusCreditsUsed: Double?, + bonusCreditsTotal: Double?, + bonusCreditsRemaining: Double?, + bonusExpiryDays: Int?, + overagesStatus: String?, + overageCreditsUsed: Double?, + estimatedOverageCostUSD: Double?, + manageURL: String?, + contextUsage: KiroContextUsageSnapshot?) + { + self.planName = planName + self.displayPlanName = displayPlanName + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.creditsRemaining = creditsRemaining + self.bonusCreditsUsed = bonusCreditsUsed + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusCreditsRemaining = bonusCreditsRemaining + self.bonusExpiryDays = bonusExpiryDays + self.overagesStatus = overagesStatus + self.overageCreditsUsed = overageCreditsUsed + self.estimatedOverageCostUSD = estimatedOverageCostUSD + self.manageURL = manageURL + self.contextUsage = contextUsage + } } public enum KiroStatusProbeError: LocalizedError, Sendable { @@ -105,9 +248,19 @@ public struct KiroStatusProbe: Sendable { } public func fetch() async throws -> KiroUsageSnapshot { - try await self.ensureLoggedIn() + let account = try await self.ensureLoggedIn() let output = try await self.runUsageCommand() - return try self.parse(output: output) + var contextUsage: KiroContextUsageSnapshot? + do { + contextUsage = try await self.fetchContextUsage() + } catch { + Self.logger.debug("Kiro context usage probe failed: \(error.localizedDescription)") + } + return try self.parse( + output: output, + accountEmail: account.email, + authMethod: account.authMethod, + contextUsage: contextUsage) } private struct KiroCLIResult { @@ -117,15 +270,20 @@ public struct KiroStatusProbe: Sendable { let terminatedForIdle: Bool } - private func ensureLoggedIn() async throws { + struct KiroAccountInfo: Equatable { + let authMethod: String? + let email: String? + } + + private func ensureLoggedIn() async throws -> KiroAccountInfo { let result = try await self.runCommand(arguments: ["whoami"], timeout: 5.0) - try self.validateWhoAmIOutput( + return try self.validateWhoAmIOutput( stdout: result.stdout, stderr: result.stderr, terminationStatus: result.terminationStatus) } - func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws { + func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { let trimmedStdout = stdout.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedStderr = stderr.trimmingCharacters(in: .whitespacesAndNewlines) let combined = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr @@ -145,6 +303,39 @@ public struct KiroStatusProbe: Sendable { if combined.isEmpty { throw KiroStatusProbeError.cliFailed("Kiro CLI whoami returned no output.") } + + return self.parseWhoAmIOutput(combined) + } + + func parseWhoAmIOutput(_ output: String) -> KiroAccountInfo { + let stripped = Self.stripANSI(output) + var authMethod: String? + var email: String? + for rawLine in stripped.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + if line.localizedCaseInsensitiveContains("logged in with") { + authMethod = line.replacingOccurrences( + of: #"(?i)^\s*logged in with\s+"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } else if line.localizedCaseInsensitiveContains("email:") { + email = line.replacingOccurrences( + of: #"(?i)^\s*email:\s*"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } else if email == nil, + !line.contains(" "), + line.contains("@") + { + email = line + } + } + return KiroAccountInfo( + authMethod: authMethod?.nilIfEmpty, + email: email?.nilIfEmpty) } private func runUsageCommand() async throws -> String { @@ -188,6 +379,17 @@ public struct KiroStatusProbe: Sendable { return result.stdout } + private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? { + let result = try await self.runCommand( + arguments: ["chat", "--no-interactive", "/context"], + timeout: 8.0, + idleTimeout: 3.0) + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? result.stderr + : result.stdout + return self.parseContextUsage(output: output) + } + private func runCommand( arguments: [String], timeout: TimeInterval, @@ -333,7 +535,12 @@ public struct KiroStatusProbe: Sendable { } } - func parse(output: String) throws -> KiroUsageSnapshot { + func parse( + output: String, + accountEmail: String? = nil, + authMethod: String? = nil, + contextUsage: KiroContextUsageSnapshot? = nil) throws -> KiroUsageSnapshot + { let stripped = Self.stripANSI(output) let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines) @@ -361,37 +568,15 @@ public struct KiroStatusProbe: Sendable { var matchedCredits = false var matchedNewFormat = false - // Parse plan name from "| KIRO FREE" or similar (legacy format) - var planName = "Kiro" - if let planMatch = stripped.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) { - let raw = String(stripped[planMatch]).replacingOccurrences(of: "|", with: "") - planName = raw.trimmingCharacters(in: .whitespaces) - } - - // Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+) - if let newPlanMatch = stripped.range(of: #"Plan:\s*(.+)"#, options: .regularExpression) { - let line = String(stripped[newPlanMatch]) - // Extract just the plan name, stopping at newline - let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces) - if let firstLine = planLine.split(separator: "\n").first { - planName = String(firstLine).trimmingCharacters(in: .whitespaces) - matchedNewFormat = true - } - } + let parsedPlan = Self.parsePlanName(from: stripped) + let planName = parsedPlan.name + matchedNewFormat = parsedPlan.matchedNewFormat // Check if this is a managed plan with no usage data let isManagedPlan = lowered.contains("managed by admin") || lowered.contains("managed by organization") - // Parse reset date from "resets on 01/01" - var resetsAt: Date? - if let resetMatch = stripped.range(of: #"resets on (\d{2}/\d{2})"#, options: .regularExpression) { - let resetStr = String(stripped[resetMatch]) - if let dateRange = resetStr.range(of: #"\d{2}/\d{2}"#, options: .regularExpression) { - let dateStr = String(resetStr[dateRange]) - resetsAt = Self.parseResetDate(dateStr) - } - } + let resetsAt = Self.parseResetDate(in: stripped) // Parse credits percentage from "████...█ X%" var creditsPercent: Double = 0 @@ -420,24 +605,24 @@ public struct KiroStatusProbe: Sendable { creditsPercent = (creditsUsed / creditsTotal) * 100.0 } - // Parse bonus credits from "Bonus credits: X.XX/Y credits used, expires in Z days" - var bonusUsed: Double? - var bonusTotal: Double? - var bonusExpiryDays: Int? - if let bonusMatch = stripped.range(of: #"Bonus credits:\s*(\d+\.?\d*)/(\d+)"#, options: .regularExpression) { - let bonusStr = String(stripped[bonusMatch]) - let numbers = bonusStr.matches(of: /(\d+\.?\d*)/) - if numbers.count >= 2 { - bonusUsed = Double(String(numbers[0].output.1)) - bonusTotal = Double(String(numbers[1].output.1)) - } - } - if let expiryMatch = stripped.range(of: #"expires in (\d+) days?"#, options: .regularExpression) { - let expiryStr = String(stripped[expiryMatch]) - if let numMatch = expiryStr.range(of: #"\d+"#, options: .regularExpression) { - bonusExpiryDays = Int(String(expiryStr[numMatch])) - } - } + let bonusCredits = Self.parseBonusCredits(in: stripped) + + let overagesStatus = Self.firstCapture( + in: stripped, + pattern: #"(?i)Overages:\s*([^\n]+)"#) + .map(Self.cleanInlineValue) + .flatMap(\.nilIfEmpty) + let overageCreditsUsed = Self.firstCapture( + in: stripped, + pattern: #"(?i)Credits used:\s*(\d+\.?\d*)"#) + .flatMap(Double.init) + let estimatedOverageCostUSD = Self.firstCapture( + in: stripped, + pattern: #"(?i)Est\.\s*cost:\s*\$?(\d+\.?\d*)\s*USD"#) + .flatMap(Double.init) + let manageURL = Self.firstCapture( + in: stripped, + pattern: #"https://app\.kiro\.dev/account/usage"#) // Managed plans in new format may omit usage metrics. Only fall back to zeros when // we did not parse any usage values, so we do not mask real metrics. @@ -445,12 +630,20 @@ public struct KiroStatusProbe: Sendable { // Managed plans don't expose credits; return snapshot with plan name only return KiroUsageSnapshot( planName: planName, + displayPlanName: Self.displayPlanName(planName), + accountEmail: accountEmail?.nilIfEmpty, + authMethod: authMethod?.nilIfEmpty, creditsUsed: 0, creditsTotal: 0, creditsPercent: 0, - bonusCreditsUsed: nil, - bonusCreditsTotal: nil, - bonusExpiryDays: nil, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays, + overagesStatus: overagesStatus, + overageCreditsUsed: overageCreditsUsed, + estimatedOverageCostUSD: estimatedOverageCostUSD, + manageURL: manageURL, + contextUsage: contextUsage, resetsAt: nil, updatedAt: Date()) } @@ -464,16 +657,41 @@ public struct KiroStatusProbe: Sendable { return KiroUsageSnapshot( planName: planName, + displayPlanName: Self.displayPlanName(planName), + accountEmail: accountEmail?.nilIfEmpty, + authMethod: authMethod?.nilIfEmpty, creditsUsed: creditsUsed, creditsTotal: creditsTotal, creditsPercent: creditsPercent, - bonusCreditsUsed: bonusUsed, - bonusCreditsTotal: bonusTotal, - bonusExpiryDays: bonusExpiryDays, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays, + overagesStatus: overagesStatus, + overageCreditsUsed: overageCreditsUsed, + estimatedOverageCostUSD: estimatedOverageCostUSD, + manageURL: manageURL, + contextUsage: contextUsage, resetsAt: resetsAt, updatedAt: Date()) } + func parseContextUsage(output: String) -> KiroContextUsageSnapshot? { + let stripped = Self.stripANSI(output) + guard let total = Self.firstCapture( + in: stripped, + pattern: #"(?i)Context window:\s*(\d+\.?\d*)%\s+used"#) + .flatMap(Double.init) + else { + return nil + } + return KiroContextUsageSnapshot( + totalPercentUsed: total, + contextFilesPercent: Self.percent(after: "Context files", in: stripped), + toolsPercent: Self.percent(after: "Tools", in: stripped), + kiroResponsesPercent: Self.percent(after: "Kiro responses", in: stripped), + promptsPercent: Self.percent(after: "Your prompts", in: stripped)) + } + private static func stripANSI(_ text: String) -> String { // Remove ANSI escape sequences let pattern = #"\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07"# @@ -484,7 +702,87 @@ public struct KiroStatusProbe: Sendable { return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") } + private static func parsePlanName(from text: String) -> (name: String, matchedNewFormat: Bool) { + var planName = "Kiro" + var matchedNewFormat = false + + // Parse plan name from "| KIRO FREE" or similar (legacy format) + if let planMatch = text.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) { + let raw = String(text[planMatch]).replacingOccurrences(of: "|", with: "") + planName = raw.trimmingCharacters(in: .whitespaces) + } + + // Parse plan name from "Estimated Usage | resets on 2026-06-01 | KIRO FREE" (kiro-cli 2.x) + if let estimatedMatch = text.range( + of: #"Estimated Usage\s*\|[^\n|]*\|\s*([A-Z][A-Z0-9 ]+)"#, + options: .regularExpression) + { + let line = String(text[estimatedMatch]) + if let plan = line.split(separator: "|").last?.trimmingCharacters(in: .whitespacesAndNewlines), + !plan.isEmpty + { + planName = plan + } + } + + // Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+) + if let newPlanMatch = text.range(of: #"Plan:\s*(.+)"#, options: .regularExpression) { + let line = String(text[newPlanMatch]) + let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces) + if let firstLine = planLine.split(separator: "\n").first { + planName = String(firstLine).trimmingCharacters(in: .whitespaces) + matchedNewFormat = true + } + } + + return (planName, matchedNewFormat) + } + + private static func parseResetDate(in text: String) -> Date? { + guard let resetMatch = text.range( + of: #"resets on (\d{4}-\d{2}-\d{2}|\d{2}/\d{2})"#, + options: .regularExpression) + else { return nil } + + let resetStr = String(text[resetMatch]) + guard let dateRange = resetStr.range( + of: #"\d{4}-\d{2}-\d{2}|\d{2}/\d{2}"#, + options: .regularExpression) + else { return nil } + + return Self.parseResetDate(String(resetStr[dateRange])) + } + + private static func parseBonusCredits(in text: String) -> (used: Double?, total: Double?, expiryDays: Int?) { + var used: Double? + var total: Double? + var expiryDays: Int? + if let bonusMatch = text.range(of: #"Bonus credits:\s*(\d+\.?\d*)/(\d+)"#, options: .regularExpression) { + let bonusStr = String(text[bonusMatch]) + let numbers = bonusStr.matches(of: /(\d+\.?\d*)/) + if numbers.count >= 2 { + used = Double(String(numbers[0].output.1)) + total = Double(String(numbers[1].output.1)) + } + } + if let expiryMatch = text.range(of: #"expires in (\d+) days?"#, options: .regularExpression) { + let expiryStr = String(text[expiryMatch]) + if let numMatch = expiryStr.range(of: #"\d+"#, options: .regularExpression) { + expiryDays = Int(String(expiryStr[numMatch])) + } + } + return (used, total, expiryDays) + } + private static func parseResetDate(_ dateStr: String) -> Date? { + if dateStr.contains("-") { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = Calendar.current.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: dateStr) + } + // Format: MM/DD - assume current or next year let parts = dateStr.split(separator: "/") guard parts.count == 2, @@ -510,6 +808,45 @@ public struct KiroStatusProbe: Sendable { return calendar.date(from: components) } + public static func displayPlanName(_ planName: String) -> String { + let cleaned = Self.cleanInlineValue(planName) + .replacingOccurrences(of: #"\s+"#, with: " ", options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard cleaned.localizedCaseInsensitiveContains("KIRO") else { + return cleaned.isEmpty ? planName : cleaned + } + return cleaned + .split(separator: " ") + .map { word in + if word.caseInsensitiveCompare("KIRO") == .orderedSame { return "Kiro" } + return word.prefix(1).uppercased() + word.dropFirst().lowercased() + } + .joined(separator: " ") + } + + private static func percent(after label: String, in text: String) -> Double? { + let escaped = NSRegularExpression.escapedPattern(for: label) + return self.firstCapture( + in: text, + pattern: #"(?i)"# + escaped + #"\s+(\d+\.?\d*)%"#) + .flatMap(Double.init) + } + + private static func firstCapture(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsRange = NSRange(text.startIndex.. 1 ? 1 : 0 + guard let range = Range(match.range(at: captureIndex), in: text) else { return nil } + return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func cleanInlineValue(_ text: String) -> String { + self.stripANSI(text) + .replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func isUsageOutputComplete(_ output: String) -> Bool { let stripped = self.stripANSI(output).lowercased() return stripped.contains("covered in plan") @@ -519,3 +856,9 @@ public struct KiroStatusProbe: Sendable { || stripped.contains("managed by admin") } } + +extension String { + fileprivate var nilIfEmpty: String? { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift new file mode 100644 index 000000000..8e8260915 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieHeader.swift @@ -0,0 +1,42 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum ManusCookieHeader { + public static let sessionCookieName = "session_id" + + public static func resolveToken(context: ProviderFetchContext) -> String? { + guard let settings = context.settings?.manus, settings.cookieSource == .manual else { return nil } + return self.token(from: settings.manualCookieHeader) + } + + public static func token(from raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + if !raw.contains("="), !raw.contains(";") { + return raw + } + + let pairs = CookieHeaderNormalizer.pairs(from: raw) + for pair in pairs where pair.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame { + let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + return token + } + } + return nil + } + + public static func sessionToken(from cookies: [HTTPCookie]) -> String? { + for cookie in cookies where cookie.name.caseInsensitiveCompare(self.sessionCookieName) == .orderedSame { + let token = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + return token + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift new file mode 100644 index 000000000..e8affb76e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusCookieImporter.swift @@ -0,0 +1,188 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum ManusCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.manusCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["manus.im", "www.manus.im"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.manus]?.browserCookieOrder ?? Browser.defaultImportOrder + nonisolated(unsafe) static var importSessionOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)? + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var sessionToken: String? { + ManusCookieHeader.sessionToken(from: self.cookies) + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return try override(browserDetection, logger) + } + if let override = self.importSessionOverrideForTesting { + return try [override(browserDetection, logger)] + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw ManusCookieImportError.noCookies + } + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { message in self.emit(message, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + let session = SessionInfo(cookies: httpCookies, sourceLabel: label) + guard let token = session.sessionToken else { + continue + } + + log("Found \(ManusCookieHeader.sessionCookieName) cookie in \(label)") + if !token.isEmpty { + sessions.append(session) + } + } + + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw ManusCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession(browserDetection: browserDetection, logger: logger) + return true + } catch { + return false + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[manus-cookie] \(message)") + self.log.debug(message) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } +} + +enum ManusCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Manus session cookies found in browsers. Please log into manus.im." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift new file mode 100644 index 000000000..838c6e5eb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusProviderDescriptor.swift @@ -0,0 +1,182 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum ManusProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .manus, + metadata: ProviderMetadata( + id: .manus, + displayName: "Manus", + sessionLabel: "Monthly credits", + weeklyLabel: "Daily refresh", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Manus usage", + cliName: "manus", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://manus.im", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .manus, + iconResourceName: "ProviderIcon-manus", + color: ProviderColor(red: 52 / 255, green: 50 / 255, blue: 45 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Manus cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ManusWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "manus", + aliases: [], + versionDetector: nil)) + } +} + +struct ManusWebFetchStrategy: ProviderFetchStrategy { + private enum SessionTokenSource { + case manual + case cache + case browser + case environment + + var shouldCacheAfterFetch: Bool { + self == .browser + } + } + + private struct ResolvedSessionToken { + let value: String + let source: SessionTokenSource + } + + let id: String = "manus.web" + let kind: ProviderFetchKind = .web + private static let log = CodexBarLog.logger(LogCategories.manusWeb) + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.manus?.cookieSource != .off else { return false } + if context.settings?.manus?.cookieSource == .manual { return true } + + if let cached = CookieHeaderCache.load(provider: .manus), + ManusCookieHeader.token(from: cached.cookieHeader) != nil + { + return true + } + + #if os(macOS) + if ManusCookieImporter.hasSession(browserDetection: context.browserDetection) { + return true + } + #endif + + return ManusSettingsReader.sessionToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let resolvedTokens = try self.resolveSessionTokens(context: context) + guard !resolvedTokens.isEmpty else { + throw ManusAPIError.missingToken + } + + var sawInvalidToken = false + for resolved in resolvedTokens { + do { + let response = try await ManusUsageFetcher.fetchCredits(sessionToken: resolved.value) + self.cacheTokenIfNeeded(resolved, sourceLabel: "web") + return self.makeResult( + usage: response.toUsageSnapshot(), + sourceLabel: "web") + } catch ManusAPIError.invalidToken { + sawInvalidToken = true + if resolved.source == .cache { + CookieHeaderCache.clear(provider: .manus) + } + continue + } + } + + if sawInvalidToken { + throw ManusAPIError.invalidToken + } + throw ManusAPIError.missingToken + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + if case ManusAPIError.missingToken = error { return false } + if case ManusAPIError.invalidCookie = error { return false } + if case ManusAPIError.invalidToken = error { return false } + return true + } + + private func resolveSessionTokens(context: ProviderFetchContext) throws -> [ResolvedSessionToken] { + guard context.settings?.manus?.cookieSource != .off else { return [] } + + if context.settings?.manus?.cookieSource == .manual { + guard let token = ManusCookieHeader.resolveToken(context: context) else { + throw ManusAPIError.invalidCookie + } + return [ResolvedSessionToken(value: token, source: .manual)] + } + + var tokens: [ResolvedSessionToken] = [] + + if let cached = CookieHeaderCache.load(provider: .manus), + let token = ManusCookieHeader.token(from: cached.cookieHeader) + { + tokens.append(ResolvedSessionToken(value: token, source: .cache)) + } + + tokens.append(contentsOf: self.resolveBrowserOrEnvironmentTokens(context: context)) + return self.deduplicated(tokens) + } + + private func resolveBrowserOrEnvironmentTokens(context: ProviderFetchContext) -> [ResolvedSessionToken] { + guard context.settings?.manus?.cookieSource != .off else { return [] } + var tokens: [ResolvedSessionToken] = [] + + #if os(macOS) + do { + let sessions = try ManusCookieImporter.importSessions(browserDetection: context.browserDetection) + tokens.append(contentsOf: sessions.compactMap { session in + guard let token = session.sessionToken else { return nil } + return ResolvedSessionToken(value: token, source: .browser) + }) + } catch { + Self.log.debug("No Manus browser session available: \(error.localizedDescription)") + } + #endif + + if let token = ManusSettingsReader.sessionToken(environment: context.env) { + tokens.append(ResolvedSessionToken(value: token, source: .environment)) + } + return self.deduplicated(tokens) + } + + private func deduplicated(_ tokens: [ResolvedSessionToken]) -> [ResolvedSessionToken] { + var seen: Set = [] + var deduplicated: [ResolvedSessionToken] = [] + for token in tokens where !token.value.isEmpty { + if seen.insert(token.value).inserted { + deduplicated.append(token) + } + } + return deduplicated + } + + private func cacheTokenIfNeeded(_ token: ResolvedSessionToken, sourceLabel: String) { + guard token.source.shouldCacheAfterFetch else { return } + CookieHeaderCache.store( + provider: .manus, + cookieHeader: "\(ManusCookieHeader.sessionCookieName)=\(token.value)", + sourceLabel: sourceLabel) + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift new file mode 100644 index 000000000..fa5874c12 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusSettingsReader.swift @@ -0,0 +1,32 @@ +import Foundation + +public enum ManusSettingsReader { + public static func sessionToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + let rawToken = environment["MANUS_SESSION_TOKEN"] + ?? environment["manus_session_token"] + ?? environment["MANUS_SESSION_ID"] + ?? environment["manus_session_id"] + if let token = ManusCookieHeader.token(from: self.cleaned(rawToken)) { + return token + } + + let rawCookie = environment["MANUS_COOKIE"] ?? environment["manus_cookie"] + return ManusCookieHeader.token(from: self.cleaned(rawCookie)) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift new file mode 100644 index 000000000..b59f37b07 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Manus/ManusUsageFetcher.swift @@ -0,0 +1,295 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ManusCreditsResponse: Decodable, Sendable { + public let totalCredits: Double + public let freeCredits: Double + public let periodicCredits: Double + public let addonCredits: Double + public let refreshCredits: Double + public let maxRefreshCredits: Double + public let proMonthlyCredits: Double + public let eventCredits: Double + public let nextRefreshTime: Date? + public let refreshInterval: String? + + public init( + totalCredits: Double, + freeCredits: Double, + periodicCredits: Double, + addonCredits: Double, + refreshCredits: Double, + maxRefreshCredits: Double, + proMonthlyCredits: Double, + eventCredits: Double, + nextRefreshTime: Date? = nil, + refreshInterval: String? = nil) + { + self.totalCredits = totalCredits + self.freeCredits = freeCredits + self.periodicCredits = periodicCredits + self.addonCredits = addonCredits + self.refreshCredits = refreshCredits + self.maxRefreshCredits = maxRefreshCredits + self.proMonthlyCredits = proMonthlyCredits + self.eventCredits = eventCredits + self.nextRefreshTime = nextRefreshTime + self.refreshInterval = refreshInterval + } + + private enum CodingKeys: String, CodingKey { + case totalCredits + case freeCredits + case periodicCredits + case addonCredits + case refreshCredits + case maxRefreshCredits + case proMonthlyCredits + case eventCredits + case nextRefreshTime + case refreshInterval + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.totalCredits = container.decodeLossyDoubleIfPresent(forKey: .totalCredits) ?? 0 + self.freeCredits = container.decodeLossyDoubleIfPresent(forKey: .freeCredits) ?? 0 + self.periodicCredits = container.decodeLossyDoubleIfPresent(forKey: .periodicCredits) ?? 0 + self.addonCredits = container.decodeLossyDoubleIfPresent(forKey: .addonCredits) ?? 0 + self.refreshCredits = container.decodeLossyDoubleIfPresent(forKey: .refreshCredits) ?? 0 + self.maxRefreshCredits = container.decodeLossyDoubleIfPresent(forKey: .maxRefreshCredits) ?? 0 + self.proMonthlyCredits = container.decodeLossyDoubleIfPresent(forKey: .proMonthlyCredits) ?? 0 + self.eventCredits = container.decodeLossyDoubleIfPresent(forKey: .eventCredits) ?? 0 + self.nextRefreshTime = container.decodeIfPresentFlexibleDate(forKey: .nextRefreshTime) + self.refreshInterval = try? container.decodeIfPresent(String.self, forKey: .refreshInterval) + } +} + +public enum ManusUsageFetcher { + private static let log = CodexBarLog.logger(LogCategories.manusAPI) + private static let creditsURL = + URL(string: "https://api.manus.im/user.v1.UserService/GetAvailableCredits")! + @TaskLocal static var fetchCreditsOverride: + (@Sendable (String, Date) async throws -> ManusCreditsResponse)? + + public static func fetchCredits( + sessionToken: String, + now: Date = Date()) async throws -> ManusCreditsResponse + { + if let override = self.fetchCreditsOverride { + return try await override(sessionToken, now) + } + + guard !sessionToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ManusAPIError.missingToken + } + + var request = URLRequest(url: self.creditsURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.httpBody = Data("{}".utf8) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization") + request.setValue("https://manus.im", forHTTPHeaderField: "Origin") + request.setValue("https://manus.im/", forHTTPHeaderField: "Referer") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" + request.setValue( + userAgent, + forHTTPHeaderField: "User-Agent") + + 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) ?? "" + let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body + Self.log.error("Manus API returned \(response.statusCode): \(truncated)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ManusAPIError.invalidToken + } + throw ManusAPIError.apiError("HTTP \(response.statusCode)") + } + + do { + return try self.parseResponse(data) + } catch let error as ManusAPIError { + throw error + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("Manus parse failed: \(error) — response: \(preview)") + throw ManusAPIError.parseFailed(error.localizedDescription) + } + } + + public static func parseResponse(_ data: Data) throws -> ManusCreditsResponse { + let decoder = JSONDecoder() + + // Try envelope first — the direct decoder defaults missing fields to 0, + // so it would "succeed" on wrapped payloads and silently return zero credits. + if let envelope = try? decoder.decode(ManusCreditsEnvelope.self, from: data), + let response = envelope.data ?? envelope.result ?? envelope.response ?? envelope.availableCredits + { + return response + } + + let response = try decoder.decode(ManusCreditsResponse.self, from: data) + // The custom decoder defaults every numeric field to 0, so an unrelated JSON + // object (e.g. an error payload) would otherwise surface as a bogus zero-credit + // snapshot. Require at least one known credits key in the raw payload. + guard Self.payloadContainsCreditsField(data: data) else { + throw ManusAPIError.parseFailed("response missing expected credits fields") + } + return response + } + + private static let expectedCreditsKeys: Set = [ + "totalCredits", "freeCredits", "periodicCredits", "addonCredits", + "refreshCredits", "maxRefreshCredits", "proMonthlyCredits", "eventCredits", + ] + + private static func payloadContainsCreditsField(data: Data) -> Bool { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return !Self.expectedCreditsKeys.isDisjoint(with: object.keys) + } +} + +extension ManusCreditsResponse { + public func toUsageSnapshot(now: Date = Date()) -> UsageSnapshot { + let primary: RateWindow? = if self.proMonthlyCredits > 0 { + RateWindow( + usedPercent: min( + 100, + max(0, (self.proMonthlyCredits - self.periodicCredits) / self.proMonthlyCredits * 100)), + windowMinutes: nil, + resetsAt: nil, + resetDescription: Self.monthlyDetail(totalCredits: self.totalCredits, freeCredits: self.freeCredits)) + } else { + nil + } + + let secondary: RateWindow? = if self.maxRefreshCredits > 0 { + RateWindow( + usedPercent: min( + 100, + max(0, (self.maxRefreshCredits - self.refreshCredits) / self.maxRefreshCredits * 100)), + windowMinutes: nil, + resetsAt: self.nextRefreshTime, + resetDescription: Self.refreshDetail( + refreshCredits: self.refreshCredits, + maxRefreshCredits: self.maxRefreshCredits, + refreshInterval: self.refreshInterval)) + } else { + nil + } + + let balance = Self.creditCountString(self.totalCredits) + let identity = ProviderIdentitySnapshot( + providerID: .manus, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: \(balance) credits") + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: now, + identity: identity) + } + + private static func creditCountString(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.maximumFractionDigits = 0 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value.rounded())) ?? String(Int(value.rounded())) + } + + private static func monthlyDetail(totalCredits: Double, freeCredits: Double) -> String? { + let total = self.creditCountString(totalCredits) + let free = self.creditCountString(freeCredits) + return "Total \(total) • Free \(free)" + } + + private static func refreshDetail( + refreshCredits: Double, + maxRefreshCredits: Double, + refreshInterval: String?) -> String? + { + let refresh = self.creditCountString(refreshCredits) + let maxRefresh = self.creditCountString(maxRefreshCredits) + if let refreshInterval, !refreshInterval.isEmpty { + return "\(refreshInterval.capitalized): \(refresh) / \(maxRefresh)" + } + return "\(refresh) / \(maxRefresh)" + } +} + +public enum ManusAPIError: LocalizedError, Equatable, Sendable { + case missingToken + case invalidCookie + case invalidToken + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingToken: + "No Manus session token provided." + case .invalidCookie: + "Manus session cookie is invalid." + case .invalidToken: + "Invalid Manus session token." + case let .networkError(message): + "Manus network error: \(message)" + case let .apiError(message): + "Manus API error: \(message)" + case let .parseFailed(message): + "Failed to parse Manus response: \(message)" + } + } +} + +private struct ManusCreditsEnvelope: Decodable { + let data: ManusCreditsResponse? + let result: ManusCreditsResponse? + let response: ManusCreditsResponse? + let availableCredits: ManusCreditsResponse? +} + +extension KeyedDecodingContainer where K: CodingKey { + fileprivate func decodeLossyDoubleIfPresent(forKey key: K) -> Double? { + if let value = try? self.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let intValue = try? self.decodeIfPresent(Int.self, forKey: key) { + return Double(intValue) + } + if let stringValue = try? self.decodeIfPresent(String.self, forKey: key) { + return Double(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + fileprivate func decodeIfPresentFlexibleDate(forKey key: K) -> Date? { + if let value = try? self.decodeIfPresent(Date.self, forKey: key) { + return value + } + guard let stringValue = try? self.decodeIfPresent(String.self, forKey: key), + !stringValue.isEmpty + else { + return nil + } + return ISO8601DateFormatter().date(from: stringValue) + } +} diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift new file mode 100644 index 000000000..6cb2470af --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoCookieImporter.swift @@ -0,0 +1,240 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +enum MiMoCookieHeader { + static let requiredCookieNames: Set = [ + "api-platform_serviceToken", + "userId", + ] + static let knownCookieNames: Set = requiredCookieNames.union([ + "api-platform_ph", + "api-platform_slh", + ]) + + static func normalizedHeader(from raw: String?) -> String? { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + let pairs = CookieHeaderNormalizer.pairs(from: normalized) + guard !pairs.isEmpty else { return nil } + + var byName: [String: String] = [:] + for pair in pairs { + let name = pair.name.trimmingCharacters(in: .whitespacesAndNewlines) + let value = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard self.knownCookieNames.contains(name), !value.isEmpty else { continue } + byName[name] = value + } + + guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil } + return byName.keys.sorted().compactMap { name in + guard let value = byName[name] else { return nil } + return "\(name)=\(value)" + }.joined(separator: "; ") + } + + static func header(from cookies: [HTTPCookie]) -> String? { + let requestURL = URL(string: "https://platform.xiaomimimo.com/api/v1/balance")! + var byName: [String: HTTPCookie] = [:] + for cookie in cookies { + guard self.knownCookieNames.contains(cookie.name) else { continue } + guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if let expiry = cookie.expiresDate, expiry < Date() { continue } + guard Self.matchesRequestURL(cookie: cookie, url: requestURL) else { continue } + + if let existing = byName[cookie.name] { + if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard self.requiredCookieNames.isSubset(of: Set(byName.keys)) else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } + guard + let boundaryIndex = requestPath.index( + cookiePath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { + return true + } + return requestPath[boundaryIndex] == "/" + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } +} + +#if os(macOS) +import SweetCookieKit + +private let miMoCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mimo]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MiMoCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = [ + "platform.xiaomimimo.com", + "xiaomimimo.com", + ] + + public struct SessionInfo: Sendable { + public let cookieHeader: String + public let sourceLabel: String + + public init(cookieHeader: String, sourceLabel: String) { + self.cookieHeader = cookieHeader + self.sourceLabel = sourceLabel + } + } + + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return try override(browserDetection, logger) + } + + let log: (String) -> Void = { msg in logger?("[mimo-cookie] \(msg)") } + var sessions: [SessionInfo] = [] + let installed = miMoCookieImportOrder.cookieImportCandidates(using: browserDetection) + let labels = installed.map(\.displayName).joined(separator: ", ") + log("Cookie import candidates: \(labels)") + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + sessions.append(contentsOf: self.sessionInfos(from: sources, origin: query.origin)) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + return sessions + } + + public static func hasSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> Bool + { + (try? self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty == false) ?? false + } + + static func sessionInfos( + from sources: [BrowserCookieStoreRecords], + origin: BrowserCookieOriginStrategy = .domainBased) -> [SessionInfo] + { + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + var sessions: [SessionInfo] = [] + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let cookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: origin) + guard let cookieHeader = MiMoCookieHeader.header(from: cookies) else { + continue + } + sessions.append(SessionInfo(cookieHeader: cookieHeader, sourceLabel: label)) + } + return sessions + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { + return "Unknown" + } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): + rhs > lhs + case (nil, .some): + true + case (.some, nil): + false + case (nil, nil): + false + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift new file mode 100644 index 000000000..42e3eb6cc --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -0,0 +1,178 @@ +import CodexBarMacroSupport +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MiMoProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + #if os(macOS) + let browserOrder: BrowserCookieImportOrder = [ + .chrome, + .chromeBeta, + .chromeCanary, + ] + #else + let browserOrder: BrowserCookieImportOrder? = nil + #endif + + return ProviderDescriptor( + id: .mimo, + metadata: ProviderMetadata( + id: .mimo, + displayName: "Xiaomi MiMo", + sessionLabel: "Credits", + weeklyLabel: "Window", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Token plan credits usage.", + toggleTitle: "Show Xiaomi MiMo token plan & balance", + cliName: "mimo", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: browserOrder, + dashboardURL: "https://platform.xiaomimimo.com/#/console/balance", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .mimo, + iconResourceName: "ProviderIcon-mimo", + color: ProviderColor(red: 1.0, green: 105 / 255, blue: 0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Xiaomi MiMo cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MiMoWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mimo", + aliases: ["xiaomi-mimo"], + versionDetector: nil)) + } +} + +struct MiMoWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mimo.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mimo?.cookieSource != .off else { return false } + if context.settings?.mimo?.cookieSource == .manual { + return Self.resolveManualCookieHeader(context: context) != nil + } + if Self.resolveManualCookieHeader(context: context) != nil { + return true + } + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .mimo), + MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) != nil + { + return true + } + return MiMoCookieImporter.hasSession(browserDetection: context.browserDetection) + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard context.settings?.mimo?.cookieSource != .off else { + throw MiMoSettingsError.missingCookie + } + if context.settings?.mimo?.cookieSource == .manual { + guard let manualCookie = Self.resolveManualCookieHeader(context: context) else { + throw MiMoSettingsError.invalidCookie + } + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: manualCookie, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } + if let manualCookie = Self.resolveManualCookieHeader(context: context) { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: manualCookie, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } + + #if os(macOS) + var lastError: Error? + + if let cached = CookieHeaderCache.load(provider: .mimo), + let cachedHeader = MiMoCookieHeader.normalizedHeader(from: cached.cookieHeader) + { + do { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: cachedHeader, + environment: context.env) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } catch { + guard Self.shouldRetryNextSession(for: error) else { + throw error + } + CookieHeaderCache.clear(provider: .mimo) + lastError = error + } + } + + let sessions = try MiMoCookieImporter.importSessions(browserDetection: context.browserDetection) + guard !sessions.isEmpty else { + if let lastError { throw lastError } + throw MiMoSettingsError.missingCookie + } + + for session in sessions { + do { + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: session.cookieHeader, + environment: context.env) + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "web") + } catch { + guard Self.shouldRetryNextSession(for: error) else { + throw error + } + lastError = error + continue + } + } + + if let lastError { throw lastError } + throw MiMoSettingsError.missingCookie + #else + throw MiMoSettingsError.missingCookie + #endif + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveManualCookieHeader(context: ProviderFetchContext) -> String? { + guard context.settings?.mimo?.cookieSource == .manual else { return nil } + return MiMoCookieHeader.normalizedHeader(from: context.settings?.mimo?.manualCookieHeader) + } + + private static func shouldRetryNextSession(for error: Error) -> Bool { + if error is DecodingError { + return true + } + guard let mimoError = error as? MiMoUsageError else { + return false + } + switch mimoError { + case .invalidCredentials, .loginRequired, .parseFailed: + return true + case .networkError: + return false + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift new file mode 100644 index 000000000..7f443d587 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageFetcher.swift @@ -0,0 +1,260 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MiMoSettingsError: LocalizedError, Sendable { + case missingCookie + case invalidCookie + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first." + case .invalidCookie: + "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies." + } + } +} + +public enum MiMoUsageError: LocalizedError, Sendable { + case invalidCredentials + case loginRequired + case parseFailed(String) + case networkError(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "Xiaomi MiMo browser session expired. Log in again." + case .loginRequired: + "Xiaomi MiMo login required." + case let .parseFailed(message): + "Could not parse Xiaomi MiMo balance: \(message)" + case let .networkError(message): + "Xiaomi MiMo request failed: \(message)" + } + } +} + +public enum MiMoSettingsReader { + public static let apiURLKey = "MIMO_API_URL" + + public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let override = environment[self.apiURLKey], + let url = URL(string: override.trimmingCharacters(in: .whitespacesAndNewlines)), + let scheme = url.scheme, !scheme.isEmpty + { + return url + } + return URL(string: "https://platform.xiaomimimo.com/api/v1")! + } +} + +public enum MiMoUsageFetcher { + private static let requestTimeout: TimeInterval = 15 + + public static func fetchUsage( + cookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date()) async throws -> MiMoUsageSnapshot + { + guard let normalizedCookie = MiMoCookieHeader.normalizedHeader(from: cookieHeader) else { + throw MiMoSettingsError.invalidCookie + } + + let balanceURL = MiMoSettingsReader.apiURL(environment: environment).appendingPathComponent("balance") + let tokenDetailURL = MiMoSettingsReader.apiURL(environment: environment) + .appendingPathComponent("tokenPlan/detail") + let tokenUsageURL = MiMoSettingsReader.apiURL(environment: environment) + .appendingPathComponent("tokenPlan/usage") + + async let balanceData = self.fetchAuthenticated(url: balanceURL, cookie: normalizedCookie) + let tokenDetailData: Data? = try? await self.fetchAuthenticated(url: tokenDetailURL, cookie: normalizedCookie) + let tokenUsageData: Data? = try? await self.fetchAuthenticated(url: tokenUsageURL, cookie: normalizedCookie) + + return try await self.parseCombinedSnapshot( + balanceData: balanceData, + tokenDetailData: tokenDetailData, + tokenUsageData: tokenUsageData, + now: now) + } + + private static func fetchAuthenticated( + url: URL, + cookie: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.requestTimeout + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(cookie, forHTTPHeaderField: "Cookie") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue("UTC+01:00", forHTTPHeaderField: "x-timeZone") + request.setValue("https://platform.xiaomimimo.com", forHTTPHeaderField: "Origin") + request.setValue("https://platform.xiaomimimo.com/#/console/balance", forHTTPHeaderField: "Referer") + request.setValue( + "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", + forHTTPHeaderField: "User-Agent") + + let response = try await ProviderHTTPClient.shared.response(for: request) + + switch response.statusCode { + case 200: + break + case 401: + throw MiMoUsageError.loginRequired + case 403: + throw MiMoUsageError.invalidCredentials + default: + throw MiMoUsageError.networkError("HTTP \(response.statusCode)") + } + + return response.data + } + + static func parseCombinedSnapshot( + balanceData: Data, + tokenDetailData: Data?, + tokenUsageData: Data?, + now: Date = Date()) throws -> MiMoUsageSnapshot + { + let balanceSnapshot = try self.parseUsageSnapshot(from: balanceData, now: now) + let planDetail: (planCode: String?, periodEnd: Date?, expired: Bool) = { + guard let data = tokenDetailData, let result = try? self.parseTokenPlanDetail(from: data) else { + return (planCode: nil, periodEnd: nil, expired: false) + } + return result + }() + let planUsage: (used: Int, limit: Int, percent: Double) = { + guard let data = tokenUsageData, let result = try? self.parseTokenPlanUsage(from: data) else { + return (used: 0, limit: 0, percent: 0) + } + return result + }() + + return MiMoUsageSnapshot( + balance: balanceSnapshot.balance, + currency: balanceSnapshot.currency, + planCode: planDetail.planCode, + planPeriodEnd: planDetail.periodEnd, + planExpired: planDetail.expired, + tokenUsed: planUsage.used, + tokenLimit: planUsage.limit, + tokenPercent: planUsage.percent, + updatedAt: now) + } + + static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> MiMoUsageSnapshot { + let decoder = JSONDecoder() + let response = try decoder.decode(BalanceResponse.self, from: data) + + guard response.code == 0 else { + let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines) + if response.code == 401 { + throw MiMoUsageError.loginRequired + } + if response.code == 403 { + throw MiMoUsageError.invalidCredentials + } + throw MiMoUsageError.parseFailed(message?.isEmpty == false ? message! : "code \(response.code)") + } + + guard let data = response.data else { + throw MiMoUsageError.parseFailed("Missing balance payload") + } + guard let balance = Double(data.balance) else { + throw MiMoUsageError.parseFailed("Invalid balance value") + } + + let currency = data.currency.trimmingCharacters(in: .whitespacesAndNewlines) + guard !currency.isEmpty else { + throw MiMoUsageError.parseFailed("Missing currency") + } + + return MiMoUsageSnapshot(balance: balance, currency: currency, updatedAt: now) + } + + static func parseTokenPlanDetail(from data: Data) throws -> (planCode: String?, periodEnd: Date?, expired: Bool) { + let decoder = JSONDecoder() + let response = try decoder.decode(TokenPlanDetailResponse.self, from: data) + + guard response.code == 0, let payload = response.data else { + return (planCode: nil, periodEnd: nil, expired: false) + } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + + let periodEnd: Date? = if let dateStr = payload.currentPeriodEnd { + formatter.date(from: dateStr) + } else { + nil + } + + return (planCode: payload.planCode, periodEnd: periodEnd, expired: payload.expired) + } + + static func parseTokenPlanUsage(from data: Data) throws -> (used: Int, limit: Int, percent: Double) { + let decoder = JSONDecoder() + let response = try decoder.decode(TokenPlanUsageResponse.self, from: data) + + guard response.code == 0, + let monthUsage = response.data?.monthUsage, + let item = monthUsage.items.first + else { + return (used: 0, limit: 0, percent: 0) + } + + return (used: item.used, limit: item.limit, percent: item.percent) + } + + private struct BalanceResponse: Decodable { + let code: Int + let message: String? + let data: BalancePayload? + } + + private struct BalancePayload: Decodable { + let balance: String + let currency: String + } + + private struct TokenPlanDetailResponse: Decodable { + let code: Int + let message: String? + let data: TokenPlanDetailPayload? + } + + private struct TokenPlanDetailPayload: Decodable { + let planCode: String? + let currentPeriodEnd: String? + let expired: Bool + } + + private struct TokenPlanUsageResponse: Decodable { + let code: Int + let message: String? + let data: TokenPlanUsagePayload? + } + + private struct TokenPlanUsagePayload: Decodable { + let monthUsage: MonthUsage? + } + + private struct MonthUsage: Decodable { + let percent: Double + let items: [UsageItem] + } + + private struct UsageItem: Decodable { + let name: String + let used: Int + let limit: Int + let percent: Double + } +} diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift new file mode 100644 index 000000000..03bde859e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift @@ -0,0 +1,82 @@ +import Foundation + +public struct MiMoUsageSnapshot: Sendable { + public let balance: Double + public let currency: String + public let planCode: String? + public let planPeriodEnd: Date? + public let planExpired: Bool + public let tokenUsed: Int + public let tokenLimit: Int + public let tokenPercent: Double + public let updatedAt: Date + + public init( + balance: Double, + currency: String, + planCode: String? = nil, + planPeriodEnd: Date? = nil, + planExpired: Bool = false, + tokenUsed: Int = 0, + tokenLimit: Int = 0, + tokenPercent: Double = 0, + updatedAt: Date) + { + self.balance = balance + self.currency = currency + self.planCode = planCode + self.planPeriodEnd = planPeriodEnd + self.planExpired = planExpired + self.tokenUsed = tokenUsed + self.tokenLimit = tokenLimit + self.tokenPercent = tokenPercent + self.updatedAt = updatedAt + } +} + +extension MiMoUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let trimmedCurrency = self.currency.trimmingCharacters(in: .whitespacesAndNewlines) + let balanceText = UsageFormatter.currencyString(self.balance, currencyCode: trimmedCurrency) + + let primary: RateWindow? = { + guard self.tokenLimit > 0 else { return nil } + let usedPercent = max(0, min(100, self.tokenPercent * 100)) + let usedText = Self.fullCountString(self.tokenUsed) + let limitText = Self.fullCountString(self.tokenLimit) + let resetDesc = "\(usedText) / \(limitText) Credits" + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.planPeriodEnd, + resetDescription: resetDesc) + }() + + let planLabel: String? = { + guard let planCode = self.planCode else { return nil } + return planCode.capitalized + }() + + let identity = ProviderIdentitySnapshot( + providerID: .mimo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planLabel ?? "Balance: \(balanceText)") + + return UsageSnapshot( + primary: primary, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func fullCountString(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 8428f81f7..bf93d57a7 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -7,6 +7,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let remainsPath = "v1/api/openplatform/coding_plan/remains" + private static let billingHistoryPath = "account/amount" public var displayName: String { switch self { @@ -55,4 +56,22 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { public var apiRemainsURL: URL { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath) } + + public var dashboardURL: URL { + var components = URLComponents(string: self.baseURLString)! + components.path = "/" + Self.codingPlanPath + components.query = Self.codingPlanQuery + return components.url! + } + + public func billingHistoryURL(page: Int, limit: Int) -> URL { + var components = URLComponents(string: self.baseURLString)! + components.path = "/" + Self.billingHistoryPath + components.queryItems = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "limit", value: "\(limit)"), + URLQueryItem(name: "aggregate", value: "false"), + ] + return components.url! + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPISettingsReader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPISettingsReader.swift index ed9e41138..08d495669 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPISettingsReader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPISettingsReader.swift @@ -2,6 +2,11 @@ import Foundation public struct MiniMaxAPISettingsReader: Sendable { public static let apiTokenKey = "MINIMAX_API_KEY" + public static let codingPlanAPITokenKey = "MINIMAX_CODING_API_KEY" + public static let apiTokenEnvironmentKeys = [ + Self.codingPlanAPITokenKey, + Self.apiTokenKey, + ] public enum APIKeyKind: Sendable { case codingPlan @@ -12,7 +17,9 @@ public struct MiniMaxAPISettingsReader: Sendable { public static func apiToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { - if let token = self.cleaned(environment[apiTokenKey]) { return token } + for key in self.apiTokenEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } return nil } @@ -52,7 +59,8 @@ public enum MiniMaxAPISettingsError: LocalizedError, Sendable { public var errorDescription: String? { switch self { case .missingToken: - "MiniMax API token not found. Set apiKey in ~/.codexbar/config.json or MINIMAX_API_KEY." + "MiniMax API token not found. Set apiKey in ~/.codexbar/config.json, " + + "MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY." } } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift new file mode 100644 index 000000000..41fcf40f0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift @@ -0,0 +1,319 @@ +import Foundation + +public struct MiniMaxBillingSummary: Sendable { + public let todayTokens: Int + public let last30DaysTokens: Int + public let todayCash: Double? + public let last30DaysCash: Double? + public let daily: [MiniMaxBillingDay] + public let topMethods: [MiniMaxBillingBreakdown] + public let topModels: [MiniMaxBillingBreakdown] + public let updatedAt: Date + + public init( + todayTokens: Int, + last30DaysTokens: Int, + todayCash: Double?, + last30DaysCash: Double?, + daily: [MiniMaxBillingDay], + topMethods: [MiniMaxBillingBreakdown], + topModels: [MiniMaxBillingBreakdown], + updatedAt: Date) + { + self.todayTokens = todayTokens + self.last30DaysTokens = last30DaysTokens + self.todayCash = todayCash + self.last30DaysCash = last30DaysCash + self.daily = daily + self.topMethods = topMethods + self.topModels = topModels + self.updatedAt = updatedAt + } +} + +public struct MiniMaxBillingDay: Sendable, Equatable { + public let day: String + public let tokens: Int + public let cash: Double? + + public init(day: String, tokens: Int, cash: Double?) { + self.day = day + self.tokens = tokens + self.cash = cash + } +} + +public struct MiniMaxBillingBreakdown: Sendable, Equatable { + public let name: String + public let tokens: Int + public let cash: Double? + + public init(name: String, tokens: Int, cash: Double?) { + self.name = name + self.tokens = tokens + self.cash = cash + } +} + +struct MiniMaxBillingHistoryPayload: Decodable { + let baseResp: MiniMaxBaseResponse? + let chargeRecords: [MiniMaxBillingRecord] + let totalCount: Int? + + private enum CodingKeys: String, CodingKey { + case baseResp = "base_resp" + case chargeRecords = "charge_records" + case totalCount = "total_cnt" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.baseResp = try container.decodeIfPresent(MiniMaxBaseResponse.self, forKey: .baseResp) + self.chargeRecords = try container.decodeIfPresent([MiniMaxBillingRecord].self, forKey: .chargeRecords) ?? [] + self.totalCount = MiniMaxDecoding.decodeInt(container, forKey: .totalCount) + } +} + +struct MiniMaxBillingRecord: Decodable { + let consumeToken: Int? + let consumeInputToken: Int? + let consumeOutputToken: Int? + let consumeCash: Double? + let consumeCashAfterVoucher: Double? + let createdAt: Int? + let ymd: String? + let consumeTime: String? + let method: String? + let model: String? + + private enum CodingKeys: String, CodingKey { + case consumeToken = "consume_token" + case consumeInputToken = "consume_input_token" + case consumeOutputToken = "consume_output_token" + case consumeCash = "consume_cash" + case consumeCashAfterVoucher = "consume_cash_after_voucher" + case createdAt = "created_at" + case ymd + case consumeTime = "consume_time" + case method + case model + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.consumeToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeToken) + self.consumeInputToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeInputToken) + self.consumeOutputToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeOutputToken) + self.consumeCash = MiniMaxDecoding.decodeDouble(container, forKey: .consumeCash) + self.consumeCashAfterVoucher = MiniMaxDecoding.decodeDouble(container, forKey: .consumeCashAfterVoucher) + self.createdAt = MiniMaxDecoding.decodeInt(container, forKey: .createdAt) + self.ymd = try container.decodeIfPresent(String.self, forKey: .ymd) + self.consumeTime = try container.decodeIfPresent(String.self, forKey: .consumeTime) + self.method = try container.decodeIfPresent(String.self, forKey: .method) + self.model = try container.decodeIfPresent(String.self, forKey: .model) + } + + var tokenCount: Int { + if let consumeToken, consumeToken > 0 { return consumeToken } + return max(0, (self.consumeInputToken ?? 0) + (self.consumeOutputToken ?? 0)) + } + + var cashValue: Double? { + self.consumeCashAfterVoucher ?? self.consumeCash + } +} + +enum MiniMaxBillingHistoryParser { + static func decodePayload(data: Data) throws -> MiniMaxBillingHistoryPayload { + try JSONDecoder().decode(MiniMaxBillingHistoryPayload.self, from: data) + } + + static func parse( + data: Data, + now: Date = Date(), + calendar: Calendar = .current) throws -> MiniMaxBillingSummary + { + let payload = try self.decodePayload(data: data) + if let status = payload.baseResp?.statusCode, status != 0 { + let message = payload.baseResp?.statusMessage ?? "status_code \(status)" + throw MiniMaxUsageError.apiError(message) + } + guard !payload.chargeRecords.isEmpty || (payload.totalCount ?? 0) == 0 else { + throw MiniMaxUsageError.parseFailed("Missing MiniMax billing records.") + } + return self.aggregate(records: payload.chargeRecords, now: now, calendar: calendar) + } + + static func aggregate( + records: [MiniMaxBillingRecord], + now: Date = Date(), + calendar inputCalendar: Calendar = .current) -> MiniMaxBillingSummary + { + var calendar = inputCalendar + calendar.timeZone = inputCalendar.timeZone + + let startOfToday = calendar.startOfDay(for: now) + let startOf30Days = calendar.date(byAdding: .day, value: -29, to: startOfToday) ?? startOfToday + var daily: [String: (date: Date, tokens: Int, cash: Double, hasCash: Bool)] = [:] + var methodTotals: [String: (tokens: Int, cash: Double, hasCash: Bool)] = [:] + var modelTotals: [String: (tokens: Int, cash: Double, hasCash: Bool)] = [:] + + for record in records { + guard let date = self.recordDate(record, calendar: calendar), + date >= startOf30Days, + date <= now + else { + continue + } + + let day = self.dayString(date, calendar: calendar) + let tokens = record.tokenCount + let cash = record.cashValue + var bucket = daily[day] ?? (calendar.startOfDay(for: date), 0, 0, false) + bucket.tokens += tokens + if let cash { + bucket.cash += cash + bucket.hasCash = true + } + daily[day] = bucket + + self.add(record, tokens: tokens, cash: cash, keyPath: \.method, totals: &methodTotals) + self.add(record, tokens: tokens, cash: cash, keyPath: \.model, totals: &modelTotals) + } + + let sortedDays = daily + .sorted { $0.value.date < $1.value.date } + .map { key, value in + MiniMaxBillingDay( + day: key, + tokens: value.tokens, + cash: value.hasCash ? value.cash : nil) + } + let todayKey = self.dayString(now, calendar: calendar) + let today = daily[todayKey] + let last30Tokens = sortedDays.reduce(0) { $0 + $1.tokens } + let last30CashValues = sortedDays.compactMap(\.cash) + let last30Cash = last30CashValues.isEmpty ? nil : last30CashValues.reduce(0, +) + + return MiniMaxBillingSummary( + todayTokens: today?.tokens ?? 0, + last30DaysTokens: last30Tokens, + todayCash: (today?.hasCash == true) ? today?.cash : nil, + last30DaysCash: last30Cash, + daily: sortedDays, + topMethods: self.breakdowns(from: methodTotals), + topModels: self.breakdowns(from: modelTotals), + updatedAt: now) + } + + private static func add( + _ record: MiniMaxBillingRecord, + tokens: Int, + cash: Double?, + keyPath: KeyPath, + totals: inout [String: (tokens: Int, cash: Double, hasCash: Bool)]) + { + let rawName = record[keyPath: keyPath]?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let rawName, !rawName.isEmpty else { return } + var total = totals[rawName] ?? (0, 0, false) + total.tokens += tokens + if let cash { + total.cash += cash + total.hasCash = true + } + totals[rawName] = total + } + + private static func breakdowns(from totals: [String: (tokens: Int, cash: Double, hasCash: Bool)]) + -> [MiniMaxBillingBreakdown] + { + totals + .map { name, value in + MiniMaxBillingBreakdown( + name: name, + tokens: value.tokens, + cash: value.hasCash ? value.cash : nil) + } + .sorted { + if $0.tokens == $1.tokens { + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + return $0.tokens > $1.tokens + } + .prefix(3) + .map(\.self) + } + + static func containsRecordBefore30DayWindow( + _ records: [MiniMaxBillingRecord], + now: Date = Date(), + calendar inputCalendar: Calendar = .current) -> Bool + { + var calendar = inputCalendar + calendar.timeZone = inputCalendar.timeZone + let startOfToday = calendar.startOfDay(for: now) + let startOf30Days = calendar.date(byAdding: .day, value: -29, to: startOfToday) ?? startOfToday + return records.contains { record in + guard let date = self.recordDate(record, calendar: calendar) else { return false } + return date < startOf30Days + } + } + + private static func recordDate(_ record: MiniMaxBillingRecord, calendar: Calendar) -> Date? { + if let createdAt = record.createdAt { + let interval = createdAt > 1_000_000_000_000 + ? TimeInterval(createdAt) / 1000 + : TimeInterval(createdAt) + return Date(timeIntervalSince1970: interval) + } + if let ymd = record.ymd { + return self.parseDateOnly(ymd, calendar: calendar) + } + if let consumeTime = record.consumeTime { + return self.parseDate(consumeTime, formats: [ + "yyyy-MM-dd HH:mm:ss", + "yyyy/MM/dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ssXXXXX", + ]) + } + return nil + } + + private static func parseDateOnly(_ 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 + for format in ["yyyy-MM-dd", "yyyyMMdd", "yyyy/MM/dd"] { + formatter.dateFormat = format + if let date = formatter.date(from: trimmed) { + return calendar.startOfDay(for: date) + } + } + return nil + } + + private static func parseDate(_ text: String, formats: [String]) -> Date? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: trimmed) { return date } + } + return nil + } + + private 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) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 762177efe..703c9d5e2 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -121,6 +121,9 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { { return true } + guard Self.allowsBrowserCookieImport(context: context) else { + return false + } return MiniMaxCookieImporter.hasSession(browserDetection: context.browserDetection) #else return false @@ -130,7 +133,8 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let fetchContext = FetchContext( region: context.settings?.minimax?.apiRegion ?? .global, - environment: context.env) + environment: context.env, + includeBillingHistory: context.includeOptionalUsage) if let override = Self.resolveCookieOverride(context: context) { Self.log.debug("Using MiniMax cookie header from settings/env") let snapshot = try await MiniMaxUsageFetcher.fetchUsage( @@ -138,7 +142,8 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { authorizationToken: override.authorizationToken, groupID: override.groupID, region: fetchContext.region, - environment: fetchContext.environment) + environment: fetchContext.environment, + includeBillingHistory: context.includeOptionalUsage) return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "web") @@ -172,6 +177,11 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { } } + guard Self.allowsBrowserCookieImport(context: context) else { + if let lastError { throw lastError } + throw MiniMaxSettingsError.missingCookie + } + let sessions = (try? MiniMaxCookieImporter.importSessions( browserDetection: context.browserDetection)) ?? [] guard !sessions.isEmpty else { @@ -217,6 +227,10 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { false } + static func allowsBrowserCookieImport(context: ProviderFetchContext) -> Bool { + context.runtime == .app && ProviderInteractionContext.current == .userInitiated + } + private struct TokenContext { let tokensByLabel: [String: [String]] let groupIDByLabel: [String: String] @@ -225,6 +239,7 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { private struct FetchContext { let region: MiniMaxAPIRegion let environment: [String: String] + let includeBillingHistory: Bool } private enum FetchAttemptResult { @@ -314,7 +329,8 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { authorizationToken: token, groupID: groupID, region: fetchContext.region, - environment: fetchContext.environment) + environment: fetchContext.environment, + includeBillingHistory: fetchContext.includeBillingHistory) Self.log.debug("MiniMax \(prefix)cookies valid from \(sourceLabel)") return .success(snapshot) } catch { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift new file mode 100644 index 000000000..488b0b0f5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -0,0 +1,211 @@ +// +// MiniMaxServiceUsage.swift +// CodexBarCore +// +// Created by Sisyphus on 2026-03-25. +// + +import Foundation + +/// Represents the usage information for a specific MiniMax service. +/// +/// This struct encapsulates all the relevant details about how much of a particular +/// MiniMax service has been used within its quota window, including reset timing +/// and localized display strings. +public struct MiniMaxServiceUsage: Sendable { + /// The service identifier (e.g., "text-generation", "text-to-speech", "image") + public let serviceType: String + + /// The type of time window for the quota (e.g., "5 hours" or "Today") + /// This should be a localized string. + public let windowType: String + + /// The specific time range for the current quota window. + /// For hourly quotas: "10:00-15:00(UTC+8)" + /// For daily quotas: full date range string + public let timeRange: String + + /// The amount of quota that has been used. + public let usage: Int + + /// The total quota limit for this service in the current window + public let limit: Int + + /// The percentage of quota used (0-100) + public let percent: Double + + /// The timestamp when the quota will reset, if available + public let resetsAt: Date? + + /// A localized description of when the quota resets (e.g., "Resets in 2 hours 30 minutes") + public let resetDescription: String + + /// The remaining quota available (limit - usage) + public var remaining: Int { + max(0, self.limit - self.usage) + } + + /// The display name for this service + public var displayName: String { + let normalized = self.serviceType.lowercased() + return switch normalized { + case "text-generation": + "Text Generation" + case "text-to-speech": + "Text to Speech" + case "image": + "Image" + case "text generation": + "Text Generation" + case "text to speech": + "Text to Speech" + case "image generation": + "Image Generation" + case "text to video": + "Text to Video" + case "image to video": + "Image to Video" + case "music generation": + "Music Generation" + case "music generation · v2.6": + "Music Generation · v2.6" + case "music cover": + "Music Cover" + case "lyrics generation": + "Lyrics Generation" + case "image understanding": + "Image Understanding" + default: + self.serviceType + } + } + + /// Creates a new MiniMaxServiceUsage instance. + /// + /// - Parameters: + /// - serviceType: The service identifier + /// - windowType: The type of time window (localized) + /// - timeRange: The specific time range string + /// - usage: The amount of quota used + /// - limit: The total quota limit + /// - percent: The percentage used (0-100) + /// - resetsAt: Optional reset timestamp + /// - resetDescription: Localized reset description + public init( + serviceType: String, + windowType: String, + timeRange: String, + usage: Int, + limit: Int, + percent: Double, + resetsAt: Date?, + resetDescription: String) + { + self.serviceType = serviceType + self.windowType = windowType + self.timeRange = timeRange + self.usage = usage + self.limit = limit + self.percent = percent + self.resetsAt = resetsAt + self.resetDescription = resetDescription + } +} + +extension MiniMaxServiceUsage { + public static func parseWindowType(_ windowType: String) -> (windowType: String, windowMinutes: Int?) { + switch windowType.lowercased() { + case "5 hours", "5 小时": + return ("5 hours", 300) + case "today", "今日": + return ("Today", 1440) + default: + // Try to extract hours from string like "X hours" + if let hours = Int(windowType.components(separatedBy: .whitespaces).first ?? "") { + return (windowType, hours * 60) + } + return (windowType, nil) + } + } + + public static func parseTimeRange(_ timeRange: String, now: Date) -> Date? { + let calendar = Calendar.current + + // Handle "10:00-15:00(UTC+8)" format + if timeRange.contains("-"), timeRange.contains("("), timeRange.contains(")") { + // Extract the time part before the timezone + let components = timeRange.split(separator: "(") + guard components.count >= 1 else { return nil } + let timePart = String(components[0]).trimmingCharacters(in: .whitespaces) + + // Split by "-" to get start and end times + let timeComponents = timePart.split(separator: "-") + guard timeComponents.count == 2 else { return nil } + + let endTimeStr = String(timeComponents[1]).trimmingCharacters(in: .whitespaces) + + // Parse end time (HH:mm format) + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "HH:mm" + timeFormatter.timeZone = TimeZone.current + + guard let endTime = timeFormatter.date(from: endTimeStr) else { return nil } + + // Get today's date components + let nowComponents = calendar.dateComponents([.year, .month, .day], from: now) + let endTimeComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + // Combine today's date with end time + var combinedComponents = DateComponents() + combinedComponents.year = nowComponents.year + combinedComponents.month = nowComponents.month + combinedComponents.day = nowComponents.day + combinedComponents.hour = endTimeComponents.hour + combinedComponents.minute = endTimeComponents.minute + + guard let resultDate = calendar.date(from: combinedComponents) else { return nil } + + // If the result date is in the past (before now), add one day + if resultDate < now { + return calendar.date(byAdding: .day, value: 1, to: resultDate) + } + + return resultDate + } + + // Handle "2026/03/25 00:00 - 2026/03/26 00:00" format + if timeRange.contains(" - ") { + let dateComponents = timeRange.split(separator: " - ") + guard dateComponents.count == 2 else { return nil } + + let endDateStr = String(dateComponents[1]).trimmingCharacters(in: .whitespaces) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy/MM/dd HH:mm" + dateFormatter.timeZone = TimeZone.current + + return dateFormatter.date(from: endDateStr) + } + + return nil + } + + public static func generateResetDescription(resetsAt: Date, now: Date = Date()) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: now, to: resetsAt) + + guard let hours = components.hour, let minutes = components.minute else { + return "Resets soon" + } + + if hours > 0, minutes > 0 { + return "Resets in \(hours) hours \(minutes) minutes" + } else if hours > 0 { + return "Resets in \(hours) hour\(hours > 1 ? "s" : "")" + } else if minutes > 0 { + return "Resets in \(minutes) minute\(minutes > 1 ? "s" : "")" + } else { + return "Resets now" + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift index 57dfbd3bd..b78f709cd 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift @@ -8,6 +8,7 @@ public struct MiniMaxSettingsReader: Sendable { public static let hostKey = "MINIMAX_HOST" public static let codingPlanURLKey = "MINIMAX_CODING_PLAN_URL" public static let remainsURLKey = "MINIMAX_REMAINS_URL" + public static let billingHistoryURLKey = "MINIMAX_BILLING_HISTORY_URL" public static func cookieHeader( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? @@ -41,6 +42,12 @@ public struct MiniMaxSettingsReader: Sendable { self.url(from: environment[self.remainsURLKey]) } + public static func billingHistoryURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + self.url(from: environment[self.billingHistoryURLKey]) + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 1b78131d5..7b250d6a2 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -4,44 +4,65 @@ import FoundationNetworking #endif public struct MiniMaxUsageFetcher: Sendable { - private static let log = CodexBarLog.logger(LogCategories.minimaxUsage) + static let log = CodexBarLog.logger(LogCategories.minimaxUsage) private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let codingPlanRemainsPath = "v1/api/openplatform/coding_plan/remains" + private static let billingHistoryPath = "account/amount" + private static let billingHistoryLimit = 100 private struct RemainsContext { let authorizationToken: String? let groupID: String? } + private struct WebFetchContext { + let cookie: String + let authorizationToken: String? + let region: MiniMaxAPIRegion + let environment: [String: String] + let transport: any ProviderHTTPTransport + } + public static func fetchUsage( cookieHeader: String, authorizationToken: String? = nil, groupID: String? = nil, region: MiniMaxAPIRegion = .global, environment: [String: String] = ProcessInfo.processInfo.environment, + includeBillingHistory: Bool = true, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> MiniMaxUsageSnapshot { guard let cookie = MiniMaxCookieHeader.normalized(from: cookieHeader) else { throw MiniMaxUsageError.invalidCredentials } + let context = WebFetchContext( + cookie: cookie, + authorizationToken: authorizationToken, + region: region, + environment: environment, + transport: transport) do { - return try await self.fetchCodingPlanHTML( - cookie: cookie, - authorizationToken: authorizationToken, - region: region, - environment: environment, + let snapshot = try await self.fetchCodingPlanHTML(context: context, now: now) + return try await self.attachingBillingIfAvailable( + to: snapshot, + context: context, + includeBillingHistory: includeBillingHistory, now: now) } catch let error as MiniMaxUsageError { if case .parseFailed = error { Self.log.debug("MiniMax coding plan HTML parse failed, trying remains API") - return try await self.fetchCodingPlanRemains( - cookie: cookie, + let snapshot = try await self.fetchCodingPlanRemains( + context: context, remainsContext: RemainsContext( authorizationToken: authorizationToken, groupID: groupID), - region: region, - environment: environment, + now: now) + return try await self.attachingBillingIfAvailable( + to: snapshot, + context: context, + includeBillingHistory: includeBillingHistory, now: now) } throw error @@ -52,7 +73,7 @@ public struct MiniMaxUsageFetcher: Sendable { apiToken: String, region: MiniMaxAPIRegion = .global, now: Date = Date(), - session: URLSession = .shared) async throws -> MiniMaxUsageSnapshot + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MiniMaxUsageSnapshot { let cleaned = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { @@ -63,11 +84,11 @@ public struct MiniMaxUsageFetcher: Sendable { // user has no persisted region and we default to `.global`, retry the China endpoint when the global host // rejects the token so upgrades don't regress existing setups. if region != .global { - return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now, session: session) + return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now, transport: transport) } do { - return try await self.fetchUsageOnce(apiToken: cleaned, region: .global, now: now, session: session) + return try await self.fetchUsageOnce(apiToken: cleaned, region: .global, now: now, transport: transport) } catch let error as MiniMaxUsageError { guard case .invalidCredentials = error else { throw error } Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host") @@ -76,7 +97,7 @@ public struct MiniMaxUsageFetcher: Sendable { apiToken: cleaned, region: .chinaMainland, now: now, - session: session) + transport: transport) } catch { // Preserve the original invalid-credentials error so the fetch pipeline can fall back to web. Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials") @@ -89,7 +110,7 @@ public struct MiniMaxUsageFetcher: Sendable { apiToken: String, region: MiniMaxAPIRegion, now: Date, - session: URLSession) async throws -> MiniMaxUsageSnapshot + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { var request = URLRequest(url: region.apiRemainsURL) request.httpMethod = "GET" @@ -98,35 +119,40 @@ public struct MiniMaxUsageFetcher: Sendable { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("CodexBar", forHTTPHeaderField: "MM-API-Source") - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { throw MiniMaxUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("MiniMax returned \(httpResponse.statusCode): \(body)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) ?? "" + Self.log.error("MiniMax returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } - throw MiniMaxUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - return try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } private static func fetchCodingPlanHTML( - cookie: String, - authorizationToken: String?, - region: MiniMaxAPIRegion, - environment: [String: String], + context: WebFetchContext, now: Date) async throws -> MiniMaxUsageSnapshot { - let url = self.resolveCodingPlanURL(region: region, environment: environment) + let url = self.resolveCodingPlanURL(region: context.region, environment: context.environment) var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue(cookie, forHTTPHeaderField: "Cookie") - if let authorizationToken { + request.setValue(context.cookie, forHTTPHeaderField: "Cookie") + if let authorizationToken = context.authorizationToken { request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") } let acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" @@ -139,30 +165,38 @@ public struct MiniMaxUsageFetcher: Sendable { let origin = self.originURL(from: url) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue( - self.resolveCodingPlanRefererURL(region: region, environment: environment).absoluteString, + self.resolveCodingPlanRefererURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "referer") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await context.transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { throw MiniMaxUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("MiniMax returned \(httpResponse.statusCode): \(body)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) ?? "" + Self.log.error("MiniMax returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } - throw MiniMaxUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - if let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"), + if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - return try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: response.data, encoding: .utf8) ?? "" if html.contains("__NEXT_DATA__") { Self.log.debug("MiniMax coding plan HTML contains __NEXT_DATA__") } @@ -173,17 +207,15 @@ public struct MiniMaxUsageFetcher: Sendable { } private static func fetchCodingPlanRemains( - cookie: String, + context: WebFetchContext, remainsContext: RemainsContext, - region: MiniMaxAPIRegion, - environment: [String: String], now: Date) async throws -> MiniMaxUsageSnapshot { - let baseRemainsURL = self.resolveRemainsURL(region: region, environment: environment) + let baseRemainsURL = self.resolveRemainsURL(region: context.region, environment: context.environment) let remainsURL = self.appendGroupID(remainsContext.groupID, to: baseRemainsURL) var request = URLRequest(url: remainsURL) request.httpMethod = "GET" - request.setValue(cookie, forHTTPHeaderField: "Cookie") + request.setValue(context.cookie, forHTTPHeaderField: "Cookie") if let authorizationToken = remainsContext.authorizationToken { request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") } @@ -198,38 +230,137 @@ public struct MiniMaxUsageFetcher: Sendable { let origin = self.originURL(from: baseRemainsURL) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue( - self.resolveCodingPlanRefererURL(region: region, environment: environment).absoluteString, + self.resolveCodingPlanRefererURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "referer") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await context.transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { throw MiniMaxUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("MiniMax returned \(httpResponse.statusCode): \(body)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) ?? "" + Self.log.error("MiniMax returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } - throw MiniMaxUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - if let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"), + if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let payload = try MiniMaxUsageParser.decodePayload(data: data) - self.logCodingPlanStatus(payload: payload) - return try MiniMaxUsageParser.parseCodingPlanRemains(payload: payload, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: response.data, encoding: .utf8) ?? "" if self.looksSignedOut(html: html) { throw MiniMaxUsageError.invalidCredentials } return try MiniMaxUsageParser.parse(html: html, now: now) } + private static func attachingBillingIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + includeBillingHistory: Bool, + now: Date) async throws -> MiniMaxUsageSnapshot + { + guard includeBillingHistory else { return snapshot } + do { + let billing = try await self.fetchBillingSummary(context: context, now: now) + return snapshot.withBillingSummary(billing) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw error + } catch let error as MiniMaxUsageError { + if case .invalidCredentials = error, context.authorizationToken != nil { + throw error + } + Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") + return snapshot + } catch { + Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") + return snapshot + } + } + + private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { + var records: [MiniMaxBillingRecord] = [] + var totalCount: Int? + + var page = 1 + while true { + let url = self.resolveBillingHistoryURL( + region: context.region, + environment: context.environment, + page: page, + limit: Self.billingHistoryLimit) + let response = try await self.billingHistoryResponse(url: url, context: context) + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) ?? "" + Self.log.debug("MiniMax billing history returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + + let payload = try MiniMaxBillingHistoryParser.decodePayload(data: response.data) + if let status = payload.baseResp?.statusCode, status != 0 { + let message = payload.baseResp?.statusMessage ?? "status_code \(status)" + throw MiniMaxUsageError.apiError(message) + } + totalCount = payload.totalCount ?? totalCount + guard !payload.chargeRecords.isEmpty else { break } + records.append(contentsOf: payload.chargeRecords) + if MiniMaxBillingHistoryParser.containsRecordBefore30DayWindow(payload.chargeRecords, now: now) { break } + if let totalCount, records.count >= totalCount { break } + page += 1 + } + + return MiniMaxBillingHistoryParser.aggregate(records: records, now: now) + } + + private static func billingHistoryResponse( + url: URL, + context: WebFetchContext) async throws -> ProviderHTTPResponse + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(context.cookie, forHTTPHeaderField: "Cookie") + if let authorizationToken = context.authorizationToken { + request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") + let userAgent = + "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" + request.setValue(userAgent, forHTTPHeaderField: "user-agent") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") + let origin = self.originURL(from: url) + request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") + request.setValue(origin.appendingPathComponent("account").absoluteString, forHTTPHeaderField: "referer") + + do { + return try await context.transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw MiniMaxUsageError.networkError("Invalid response") + } catch { + throw error + } + } + private static func appendGroupID(_ groupID: String?, to url: URL) -> URL { guard let groupID, !groupID.isEmpty else { return url } guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } @@ -296,6 +427,35 @@ public struct MiniMaxUsageFetcher: Sendable { return region.remainsURL } + static func resolveBillingHistoryURL( + region: MiniMaxAPIRegion, + environment: [String: String], + page: Int, + limit: Int = Self.billingHistoryLimit) -> URL + { + if let override = MiniMaxSettingsReader.billingHistoryURL(environment: environment) { + return self.billingHistoryURL(from: override, page: page, limit: limit) + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment), + let hostURL = self.url(from: host, path: Self.billingHistoryPath) + { + return self.billingHistoryURL(from: hostURL, page: page, limit: limit) + } + return region.billingHistoryURL(page: page, limit: limit) + } + + private static func billingHistoryURL(from url: URL, page: Int, limit: Int) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + var items = components.queryItems?.filter { + $0.name != "page" && $0.name != "limit" && $0.name != "aggregate" + } ?? [] + items.append(URLQueryItem(name: "page", value: "\(page)")) + items.append(URLQueryItem(name: "limit", value: "\(limit)")) + items.append(URLQueryItem(name: "aggregate", value: "false")) + components.queryItems = items + return components.url ?? url + } + static func url(from raw: String, path: String? = nil, query: String? = nil) -> URL? { guard let cleaned = MiniMaxSettingsReader.cleaned(raw) else { return nil } @@ -326,9 +486,32 @@ public struct MiniMaxUsageFetcher: Sendable { } private static func looksSignedOut(html: String) -> Bool { - let lower = html.lowercased() + let lower = self.visibleText(from: html).lowercased() return lower.contains("sign in") || lower.contains("log in") || lower.contains("登录") || lower.contains("登入") } + + static func _looksSignedOutForTesting(html: String) -> Bool { + self.looksSignedOut(html: html) + } + + private static func visibleText(from html: String) -> String { + let patterns = [ + #"(?is)]*>.*?"#, + #"(?is)]*>.*?"#, + #"(?is)"#, + #"<[^>]+>"#, + #"\s+"#, + ] + + return patterns.enumerated().reduce(html) { result, item in + let replacement = item.offset == patterns.count - 1 ? " " : "" + return result.replacingOccurrences( + of: item.element, + with: replacement, + options: .regularExpression) + } + .trimmingCharacters(in: .whitespacesAndNewlines) + } } struct MiniMaxCodingPlanPayload: Decodable { @@ -388,27 +571,45 @@ struct MiniMaxComboCard: Decodable { } struct MiniMaxModelRemains: Decodable { + let modelName: String? let currentIntervalTotalCount: Int? let currentIntervalUsageCount: Int? let startTime: Int? let endTime: Int? let remainsTime: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? private enum CodingKeys: String, CodingKey { + case modelName = "model_name" case currentIntervalTotalCount = "current_interval_total_count" case currentIntervalUsageCount = "current_interval_usage_count" case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) } } @@ -428,6 +629,53 @@ struct MiniMaxBaseResponse: Decodable { } } +// MARK: - Multi-Service API Response Structures + +struct MiniMaxMultiServicePayload: Decodable { + let data: MiniMaxMultiServiceData +} + +struct MiniMaxMultiServiceData: Decodable { + let services: [MiniMaxServiceItem] +} + +struct MiniMaxServiceItem: Decodable { + let serviceType: String? + let windowType: String? + let timeRange: String? + let usage: Int? + let limit: Int? + let percent: Double? + + private enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case windowType = "window_type" + case timeRange = "time_range" + case usage + case limit + case percent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) + self.windowType = try container.decodeIfPresent(String.self, forKey: .windowType) + self.timeRange = try container.decodeIfPresent(String.self, forKey: .timeRange) + self.usage = MiniMaxDecoding.decodeInt(container, forKey: .usage) + self.limit = MiniMaxDecoding.decodeInt(container, forKey: .limit) + // Handle both Double and String for percent (flexible parsing) + if let percentDouble = try? container.decodeIfPresent(Double.self, forKey: .percent) { + self.percent = percentDouble + } else if let percentString = try? container.decodeIfPresent(String.self, forKey: .percent), + let percentValue = Double(percentString) + { + self.percent = percentValue + } else { + self.percent = nil + } + } +} + enum MiniMaxDecoding { static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { if let value = try? container.decodeIfPresent(Int.self, forKey: key) { @@ -445,6 +693,23 @@ enum MiniMaxDecoding { } return nil } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + } + return nil + } } enum MiniMaxUsageParser { @@ -453,6 +718,11 @@ enum MiniMaxUsageParser { return try decoder.decode(MiniMaxCodingPlanPayload.self, from: data) } + static func decodeMultiServicePayload(data: Data) throws -> MiniMaxMultiServicePayload { + let decoder = JSONDecoder() + return try decoder.decode(MiniMaxMultiServicePayload.self, from: data) + } + static func decodePayload(json: [String: Any]) throws -> MiniMaxCodingPlanPayload { let normalized = self.normalizeCodingPlanPayload(json) let data = try JSONSerialization.data(withJSONObject: normalized, options: []) @@ -460,6 +730,15 @@ enum MiniMaxUsageParser { } static func parseCodingPlanRemains(data: Data, now: Date = Date()) throws -> MiniMaxUsageSnapshot { + do { + if let multiServiceSnapshot = try self.parseMultiService(data: data, now: now) { + return multiServiceSnapshot + } + } catch { + // Log multi-service parsing failure but continue to single-service parsing + MiniMaxUsageFetcher.log.debug("MiniMax multi-service parsing failed: \(error.localizedDescription)") + } + let payload = try self.decodePayload(data: data) return try self.parseCodingPlanRemains(payload: payload, now: now) } @@ -504,29 +783,64 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + guard !payload.data.modelRemains.isEmpty else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } - let total = first.currentIntervalTotalCount - let remaining = first.currentIntervalUsageCount + // Convert model_remains to services array for multi-service UI display + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.modelRemains { + guard let modelName = item.modelName else { continue } + let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) + + if let intervalService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + start: item.startTime, + end: item.endTime, + remainsTime: item.remainsTime), + now: now) + { + services.append(intervalService) + } + + // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. + if self.isTextGenerationModelName(modelName), + let weeklyService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: "Weekly", + total: item.currentWeeklyTotalCount, + remaining: item.currentWeeklyUsageCount, + start: item.weeklyStartTime, + end: item.weeklyEndTime, + remainsTime: item.weeklyRemainsTime), + now: now) + { + services.append(weeklyService) + } + } + + // Use first service for backward compatibility fields + let first = payload.data.modelRemains.first + let total = first?.currentIntervalTotalCount + let remaining = first?.currentIntervalUsageCount let usedPercent = self.usedPercent(total: total, remaining: remaining) let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first.startTime), - end: self.dateFromEpoch(first.endTime)) + start: self.dateFromEpoch(first?.startTime), + end: self.dateFromEpoch(first?.endTime)) let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first.endTime), - remains: first.remainsTime, + end: self.dateFromEpoch(first?.endTime), + remains: first?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) - if planName == nil, total == nil, usedPercent == nil { - throw MiniMaxUsageError.parseFailed("Missing coding plan data.") - } - let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) } else { @@ -541,7 +855,8 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + services: services.isEmpty ? nil : services) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { @@ -792,7 +1107,7 @@ enum MiniMaxUsageParser { if let tzHint = timeZoneHint?.trimmingCharacters(in: .whitespacesAndNewlines), !tzHint.isEmpty { - formatter.timeZone = TimeZone(identifier: tzHint) + formatter.timeZone = self.timeZone(from: tzHint) } formatter.locale = Locale(identifier: "en_US_POSIX") @@ -822,6 +1137,33 @@ enum MiniMaxUsageParser { return 0 } + private static func timeZone(from hint: String) -> TimeZone? { + let trimmed = hint.trimmingCharacters(in: .whitespacesAndNewlines) + if let timeZone = TimeZone(identifier: trimmed) { + return timeZone + } + + let pattern = #"(?i)^(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)), + let signRange = Range(match.range(at: 1), in: trimmed), + let hourRange = Range(match.range(at: 2), in: trimmed) + else { + return nil + } + + let sign = trimmed[signRange] == "-" ? -1 : 1 + let hours = Int(trimmed[hourRange]) ?? 0 + let minutes = if match.range(at: 3).location != NSNotFound, + let minuteRange = Range(match.range(at: 3), in: trimmed) + { + Int(trimmed[minuteRange]) ?? 0 + } else { + 0 + } + return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60))) + } + private static func seconds(from value: Double, unit: String) -> TimeInterval { let lower = unit.lowercased() if lower.hasPrefix("d") { return value * 24 * 60 * 60 } @@ -861,6 +1203,282 @@ enum MiniMaxUsageParser { return String(text[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) } } + + // MARK: - Multi-Service Parsing + + private static func parseMultiService(data: Data, now: Date) throws -> MiniMaxUsageSnapshot? { + let payload = try self.decodeMultiServicePayload(data: data) + + guard !payload.data.services.isEmpty else { + return nil + } + + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.services { + guard let serviceType = item.serviceType, + let windowType = item.windowType, + let timeRange = item.timeRange, + let usage = item.usage, + let limit = item.limit, + limit > 0 + else { + continue + } + + var percent = item.percent ?? 0.0 + if item.percent == nil, limit > 0 { + percent = Double(usage) / Double(limit) * 100.0 + } + + let resetsAt = self.parseResetsAtFromTimeRange(timeRange: timeRange, windowType: windowType, now: now) + let resetDescription = self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + + let serviceTypeIdentifier: String = if serviceType.lowercased().contains("text"), + serviceType.lowercased().contains("generation") + { + "text-generation" + } else if serviceType.lowercased().contains("text"), serviceType.lowercased().contains("speech") { + "text-to-speech" + } else if serviceType.lowercased().contains("image") { + "image" + } else { + serviceType.lowercased() + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "_", with: "-") + } + + let serviceUsage = MiniMaxServiceUsage( + serviceType: serviceTypeIdentifier, + windowType: windowType, + timeRange: timeRange, + usage: usage, + limit: limit, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription) + services.append(serviceUsage) + } + + if services.isEmpty { + return nil + } + + let planName = self.extractPlanNameFromServices(services: payload.data.services) + + return MiniMaxUsageSnapshot( + planName: planName, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: services) + } + + private static func parseResetsAtFromTimeRange(timeRange: String, windowType: String, now: Date) -> Date? { + let lowerWindow = windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if lowerWindow == "today" { + let components = timeRange.split(separator: "-", maxSplits: 1) + guard components.count == 2 else { return nil } + + let endTimeStr = String(components[1].trimmingCharacters(in: .whitespacesAndNewlines)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + + return formatter.date(from: endTimeStr) + } + + if lowerWindow.contains("hour") || lowerWindow.contains("h") { + let timeComponents = timeRange.split(separator: "-") + guard timeComponents.count >= 2 else { return nil } + + let endTimePart = String(timeComponents[1]) + let endTimeClean = endTimePart.replacingOccurrences(of: "\\(.*\\)", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return self.dateForTime(endTimeClean, timeZoneHint: "UTC+8", now: now) + } + + return nil + } + + private static func resetDescription( + for windowType: String, + timeRange: String, + now: Date, + resetsAt: Date?) -> String + { + if let resetsAt, resetsAt > now { + let interval = resetsAt.timeIntervalSince(now) + if interval < 60 { + return "Resets in \(Int(interval)) seconds" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "Resets in \(minutes) minute\(minutes == 1 ? "" : "s")" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "Resets in \(hours) hour\(hours == 1 ? "" : "s")" + } else { + let days = Int(interval / 86400) + return "Resets in \(days) day\(days == 1 ? "" : "s")" + } + } + + return "\(windowType): \(timeRange)" + } + + private static func extractPlanNameFromServices(services: [MiniMaxServiceItem]) -> String? { + for service in services { + if let serviceType = service.serviceType, + serviceType.lowercased().contains("pro") || serviceType.lowercased().contains("max") + { + return serviceType + } + } + + return nil + } + + private static func parseWindowInfo( + startTime: Date?, + endTime: Date?, + now: Date) -> (windowType: String, timeRange: String) + { + guard let startTime, let endTime else { + return (windowType: "Unknown", timeRange: "N/A") + } + + let durationSeconds = endTime.timeIntervalSince(startTime) + let durationHours = durationSeconds / 3600 + + // Determine window type based on duration + let windowType = if durationHours >= 23, durationHours <= 25 { + "Today" + } else if durationHours >= 4, durationHours <= 6 { + "5 hours" + } else if durationHours >= 1, durationHours < 23 { + "\(Int(durationHours)) hours" + } else { + "Custom" + } + + // Format time range + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let startStr = formatter.string(from: startTime) + let endStr = formatter.string(from: endTime) + + let timeRange = "\(startStr)-\(endStr)(UTC+8)" + + return (windowType: windowType, timeRange: timeRange) + } + + private struct ServiceUsageInput { + let serviceType: String + let windowTypeOverride: String? + let total: Int? + let remaining: Int? + let start: Int? + let end: Int? + let remainsTime: Int? + } + + private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + if used == 0, total == 0 { return nil } + + let startTime = self.dateFromEpoch(input.start) + let endTime = self.dateFromEpoch(input.end) + var (windowType, timeRange) = self.parseWindowInfo(startTime: startTime, endTime: endTime, now: now) + if let windowTypeOverride = input.windowTypeOverride { windowType = windowTypeOverride } + if windowType.lowercased() == "weekly", + let weeklyRange = self.formatMiniMaxDateTimeRange(startTime: startTime, endTime: endTime) + { + timeRange = weeklyRange + } + + let resetsAt = self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + + let percent = Double(used) / Double(total) * 100.0 + return MiniMaxServiceUsage( + serviceType: input.serviceType, + windowType: windowType, + timeRange: timeRange, + usage: used, + limit: total, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private static func mapModelNameToServiceType(modelName: String) -> String { + // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. + if self.isTextGenerationModelName(modelName) { + return "Text Generation" + } + + let lower = modelName.lowercased() + + // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. + if lower.contains("speech") { + return "Text to Speech" + } + + // Image to Video Fast (图生视频 Fast): Hailuo-2.3-Fast + if lower.contains("hailuo"), lower.contains("fast") { + return "Image to Video" + } + + // Text to Video (文生视频): Hailuo-2.3 (non-Fast) + if lower.contains("hailuo") { + return "Text to Video" + } + + // Image Generation (图像生成): image-01, image-02, etc. + if lower.hasPrefix("image-") { + return "Image Generation" + } + + // Music Generation (音乐生成): music-2.5, etc. + if lower.contains("music") { + return "Music Generation" + } + + // Default: use model name as-is + return modelName + } + + private static func isTextGenerationModelName(_ modelName: String) -> Bool { + let lower = modelName.lowercased() + return lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { + guard let startTime, let endTime else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "MM/dd HH:mm" + let start = formatter.string(from: startTime) + let end = formatter.string(from: endTime) + return "\(start) - \(end)(UTC+8)" + } } public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..f66de9d23 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,41 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + public let services: [MiniMaxServiceUsage]? + public let billingSummary: MiniMaxBillingSummary? + + public var primaryService: MiniMaxServiceUsage? { + // Priority: "Text Generation" > first service + if let services = self.services, !services.isEmpty { + if let textGenService = services.first(where: { $0.displayName == "Text Generation" }) { + return textGenService + } + return services.first + } + return nil + } + + public var secondaryService: MiniMaxServiceUsage? { + // Return second service for RateWindow.secondary if exists + guard let services = self.services, services.count >= 2 else { return nil } + // If we have Text Generation as primary, get the next non-Text Generation service + if let textGenIndex = services.firstIndex(where: { $0.displayName == "Text Generation" }) { + // If Text Generation is first, secondary is second + if textGenIndex == 0 { + return services[1] + } + // If Text Generation is not first, secondary could be first or second depending on count + return services[0] + } + // No Text Generation found, just return second service + return services[1] + } + + public var tertiaryService: MiniMaxServiceUsage? { + // Return third service for RateWindow.tertiary if exists + guard let services = self.services, services.count >= 3 else { return nil } + return services[2] + } public init( planName: String?, @@ -18,7 +53,9 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + services: [MiniMaxServiceUsage]? = nil, + billingSummary: MiniMaxBillingSummary? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,11 +65,52 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.services = services + self.billingSummary = billingSummary + } + + public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: billingSummary) } } extension MiniMaxUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { + // If we have services array, use that for multi-service support + if let services = self.services, !services.isEmpty { + let primaryWindow = self.rateWindow(for: self.primaryService) + let secondaryWindow = self.rateWindow(for: self.secondaryService) + let tertiaryWindow = self.rateWindow(for: self.tertiaryService) + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondaryWindow, + tertiary: tertiaryWindow, + providerCost: nil, + minimaxUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + // Fallback to single-service mode for backward compatibility let used = max(0, min(100, self.usedPercent ?? 0)) let resetDescription = self.limitDescription() let primary = RateWindow( @@ -59,6 +137,16 @@ extension MiniMaxUsageSnapshot { identity: identity) } + private func rateWindow(for service: MiniMaxServiceUsage?) -> RateWindow? { + guard let service else { return nil } + let windowMinutes = self.windowMinutes(for: service) + return RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: windowMinutes, + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + private func limitDescription() -> String? { guard let availablePrompts, availablePrompts > 0 else { return self.windowDescription() @@ -82,4 +170,31 @@ extension MiniMaxUsageSnapshot { } return "\(windowMinutes) \(windowMinutes == 1 ? "minute" : "minutes")" } + + private func windowMinutes(for service: MiniMaxServiceUsage) -> Int? { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle "Today" case - 24 hours = 1440 minutes + if windowType == "today" { + return 24 * 60 + } + + // Handle time duration formats like "5 hours", "30 minutes", etc. + let components = windowType.split(separator: " ") + guard components.count >= 2 else { return nil } + + guard let value = Int(components[0]) else { return nil } + let unit = components[1].lowercased() + + switch unit { + case "hour", "hours", "h", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins", "m": + return value + case "day", "days", "d": + return value * 24 * 60 + default: + return nil + } + } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index cdd81427e..7d27f365e 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -90,7 +90,64 @@ struct MistralPrice: Codable { // MARK: - Intermediate Snapshot -public struct MistralUsageSnapshot: Sendable { +public struct MistralDailyUsageBucket: Codable, Equatable, Sendable, Identifiable { + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + + public var id: String { + self.name + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init(name: String, cost: Double, inputTokens: Int, cachedTokens: Int, outputTokens: Int) { + self.name = name + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + } + } + + public let day: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init( + day: String, + cost: Double, + inputTokens: Int, + cachedTokens: Int, + outputTokens: Int, + models: [ModelBreakdown]) + { + self.day = day + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + self.models = models + } +} + +public struct MistralUsageSnapshot: Codable, Sendable { public let totalCost: Double public let currency: String public let currencySymbol: String @@ -98,27 +155,56 @@ public struct MistralUsageSnapshot: Sendable { public let totalOutputTokens: Int public let totalCachedTokens: Int public let modelCount: Int + public let daily: [MistralDailyUsageBucket] public let startDate: Date? public let endDate: Date? public let updatedAt: Date + public init( + totalCost: Double, + currency: String, + currencySymbol: String, + totalInputTokens: Int, + totalOutputTokens: Int, + totalCachedTokens: Int, + modelCount: Int, + daily: [MistralDailyUsageBucket] = [], + startDate: Date?, + endDate: Date?, + updatedAt: Date) + { + self.totalCost = totalCost + self.currency = currency + self.currencySymbol = currencySymbol + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCachedTokens = totalCachedTokens + self.modelCount = modelCount + self.daily = daily.sorted { $0.day < $1.day } + self.startDate = startDate + self.endDate = endDate + self.updatedAt = updatedAt + } + public func toUsageSnapshot() -> UsageSnapshot { - let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 } - let costDescription = if self.totalCost > 0 { + // Negative totalCost means a refund/credit adjustment; clamp to zero rather than + // showing a confusing negative amount in the menu bar. + let spendText = if self.totalCost > 0 { "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month" } else { - "No usage this month" + "\(self.currencySymbol)0.0000 this month" } - let primary = RateWindow( - usedPercent: 0, - windowMinutes: nil, - resetsAt: resetDate, - resetDescription: costDescription) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API spend: \(spendText)") return UsageSnapshot( - primary: primary, + primary: nil, secondary: nil, providerCost: nil, + mistralUsage: self, updatedAt: self.updatedAt, - identity: nil) + identity: identity) } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift index 1e4925444..996658610 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -36,20 +36,17 @@ public enum MistralUsageFetcher { request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") } - let (data, response) = try await URLSession.shared.data(for: request) + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data - guard let httpResponse = response as? HTTPURLResponse else { - throw MistralUsageError.apiError("Invalid response type") - } - - switch httpResponse.statusCode { + switch response.statusCode { case 200: break case 401, 403: throw MistralUsageError.invalidCredentials default: let body = String(data: data.prefix(200), encoding: .utf8) ?? "" - throw MistralUsageError.apiError("HTTP \(httpResponse.statusCode): \(body)") + throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") } return try Self.parseResponse(data: data, updatedAt: now) @@ -70,45 +67,80 @@ public enum MistralUsageFetcher { var totalOutput = 0 var totalCached = 0 var modelCount = 0 + var daily: [String: DailyAccumulator] = [:] // Aggregate completion tokens if let models = billing.completion?.models { - for (_, modelData) in models { + for (modelName, modelData) in models { modelCount += 1 let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) totalInput += input totalOutput += output totalCached += cached totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) } } // Aggregate OCR, connectors, audio if present for category in [billing.ocr, billing.connectors, billing.audio] { if let models = category?.models { - for (_, modelData) in models { + for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) } } } // Aggregate libraries_api (pages + tokens) - for category in [billing.librariesApi?.pages, billing.librariesApi?.tokens] { - if let models = category?.models { - for (_, modelData) in models { - let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) - totalCost += cost - } + if let models = billing.librariesApi?.pages?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + if let models = billing.librariesApi?.tokens?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) } } // Aggregate fine_tuning (training + storage) for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { if let models { - for (_, modelData) in models { + for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) } } } @@ -127,6 +159,7 @@ public enum MistralUsageFetcher { totalOutputTokens: totalOutput, totalCachedTokens: totalCached, modelCount: modelCount, + daily: daily.values.map { $0.makeBucket() }, startDate: startDate, endDate: endDate, updatedAt: updatedAt) @@ -187,6 +220,89 @@ public enum MistralUsageFetcher { return (totalInput, totalOutput, totalCached, totalCost) } + private static func addDailyEntries( + modelName: String, + data: MistralModelUsageData, + prices: [String: Double], + daily: inout [String: DailyAccumulator], + countsTokens: Bool) + { + self.addDaily( + entries: data.input ?? [], + context: DailyEntryContext( + kind: .input, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.output ?? [], + context: DailyEntryContext( + kind: .output, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.cached ?? [], + context: DailyEntryContext( + kind: .cached, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + } + + fileprivate enum TokenKind { + case input + case cached + case output + } + + private static func addDaily( + entries: [MistralUsageEntry], + context: DailyEntryContext, + daily: inout [String: DailyAccumulator]) + { + for entry in entries { + guard let day = dayKey(from: entry.timestamp) else { continue } + let units = entry.valuePaid ?? entry.value ?? 0 + let cost = Self.cost(for: entry, units: units, prices: context.prices) + var accumulator = daily[day] ?? DailyAccumulator(day: day) + accumulator.add( + modelName: Self.displayModelName(context.modelName, entry: entry), + kind: context.kind, + units: units, + cost: cost, + countsTokens: context.countsTokens) + daily[day] = accumulator + } + } + + private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { + guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } + return Double(units) * (prices["\(metric)::\(group)"] ?? 0) + } + + private static func displayModelName(_ raw: String, entry: MistralUsageEntry) -> String { + if let display = entry.billingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), + !display.isEmpty + { + return display + } + return raw.split(separator: "::").first.map(String.init) ?? raw + } + + private static func dayKey(from timestamp: String?) -> String? { + guard let trimmed = timestamp?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + if trimmed.count >= 10 { + return String(trimmed.prefix(10)) + } + return nil + } + private static func parseDate(_ string: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -195,3 +311,79 @@ public enum MistralUsageFetcher { return formatter.date(from: string) } } + +private struct DailyEntryContext { + let kind: MistralUsageFetcher.TokenKind + let modelName: String + let prices: [String: Double] + let countsTokens: Bool +} + +private struct DailyAccumulator { + let day: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + var models: [String: ModelAccumulator] = [:] + + mutating func add( + modelName: String, + kind: MistralUsageFetcher.TokenKind, + units: Int, + cost: Double, + countsTokens: Bool) + { + self.cost += cost + var model = self.models[modelName] ?? ModelAccumulator(name: modelName) + model.cost += cost + guard countsTokens else { + self.models[modelName] = model + return + } + switch kind { + case .input: + self.inputTokens += units + model.inputTokens += units + case .cached: + self.cachedTokens += units + model.cachedTokens += units + case .output: + self.outputTokens += units + model.outputTokens += units + } + self.models[modelName] = model + } + + func makeBucket() -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: self.day, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens, + models: self.models.values + .map { $0.makeBreakdown() } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } +} + +private struct ModelAccumulator { + let name: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + + func makeBreakdown() -> MistralDailyUsageBucket.ModelBreakdown { + MistralDailyUsageBucket.ModelBreakdown( + name: self.name, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift new file mode 100644 index 000000000..61703f138 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -0,0 +1,71 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MoonshotProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .moonshot, + metadata: ProviderMetadata( + id: .moonshot, + displayName: "Moonshot / Kimi API", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Moonshot / Kimi API balance", + cliName: "moonshot", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.moonshot.ai/console/account", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .kimi, + iconResourceName: "ProviderIcon-kimi", + color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MoonshotAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "moonshot", + aliases: [], + versionDetector: nil)) + } +} + +struct MoonshotAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "moonshot.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw MoonshotUsageError.missingCredentials + } + let region = + context.settings?.moonshot?.region ?? MoonshotSettingsReader.region(environment: context.env) + let usage = try await MoonshotUsageFetcher.fetchUsage(apiKey: apiKey, region: region) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.moonshotToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift new file mode 100644 index 000000000..420e3ff1e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum MoonshotRegion: String, CaseIterable, Sendable { + case international + case china + + private static let balancePath = "v1/users/me/balance" + + public var displayName: String { + switch self { + case .international: + "International (api.moonshot.ai)" + case .china: + "China (api.moonshot.cn)" + } + } + + public var apiBaseURLString: String { + switch self { + case .international: + "https://api.moonshot.ai" + case .china: + "https://api.moonshot.cn" + } + } + + public var balanceURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.balancePath) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift new file mode 100644 index 000000000..06bfb49de --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct MoonshotSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "MOONSHOT_API_KEY", + "MOONSHOT_KEY", + ] + public static let regionEnvironmentKey = "MOONSHOT_REGION" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + + return nil + } + + public static func region( + environment: [String: String] = ProcessInfo.processInfo.environment) -> MoonshotRegion + { + guard let raw = environment[self.regionEnvironmentKey] else { + return .international + } + let cleaned = Self.cleaned(raw).lowercased() + return MoonshotRegion(rawValue: cleaned) ?? .international + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) + || (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift new file mode 100644 index 000000000..04a137d0d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift @@ -0,0 +1,161 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MoonshotUsageSnapshot: Sendable { + public let summary: MoonshotUsageSummary + + public init(summary: MoonshotUsageSummary) { + self.summary = summary + } + + public func toUsageSnapshot() -> UsageSnapshot { + self.summary.toUsageSnapshot() + } +} + +public struct MoonshotUsageSummary: Sendable { + public let availableBalance: Double + public let voucherBalance: Double + public let cashBalance: Double + public let updatedAt: Date + + public init( + availableBalance: Double, voucherBalance: Double, cashBalance: Double, updatedAt: Date) + { + self.availableBalance = availableBalance + self.voucherBalance = voucherBalance + self.cashBalance = cashBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balance = UsageFormatter.usdString(self.availableBalance) + let loginMethod: String + if self.cashBalance < 0 { + let deficit = UsageFormatter.usdString(abs(self.cashBalance)) + loginMethod = "Balance: \(balance) · \(deficit) in deficit" + } else { + loginMethod = "Balance: \(balance)" + } + let identity = ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +private struct MoonshotBalanceResponse: Decodable { + let code: Int + let data: MoonshotBalanceData + let scode: String + let status: Bool +} + +private struct MoonshotBalanceData: Decodable { + let availableBalance: Double + let voucherBalance: Double + let cashBalance: Double + + private enum CodingKeys: String, CodingKey { + case availableBalance = "available_balance" + case voucherBalance = "voucher_balance" + case cashBalance = "cash_balance" + } +} + +public enum MoonshotUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Moonshot API key." + case let .networkError(message): + "Moonshot network error: \(message)" + case let .apiError(message): + "Moonshot API error: \(message)" + case let .parseFailed(message): + "Failed to parse Moonshot response: \(message)" + } + } +} + +public struct MoonshotUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.moonshotUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + region: MoonshotRegion = .international, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MoonshotUsageSnapshot + { + let cleaned = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw MoonshotUsageError.missingCredentials + } + + var request = URLRequest(url: self.resolveBalanceURL(region: region)) + request.httpMethod = "GET" + request.setValue("Bearer \(cleaned)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw MoonshotUsageError.networkError("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + Self.log.error("Moonshot API returned HTTP \(response.statusCode)") + throw MoonshotUsageError.apiError("HTTP \(response.statusCode)") + } + + let summary = try self.parseSummary(data: response.data) + return MoonshotUsageSnapshot(summary: summary) + } + + public static func resolveBalanceURL(region: MoonshotRegion) -> URL { + region.balanceURL + } + + static func _parseSummaryForTesting(_ data: Data) throws -> MoonshotUsageSummary { + try self.parseSummary(data: data) + } + + private static func parseSummary(data: Data) throws -> MoonshotUsageSummary { + let response: MoonshotBalanceResponse + do { + response = try JSONDecoder().decode(MoonshotBalanceResponse.self, from: data) + } catch { + throw MoonshotUsageError.parseFailed(error.localizedDescription) + } + + guard response.code == 0, response.status else { + throw MoonshotUsageError.apiError("code \(response.code), scode \(response.scode)") + } + + return MoonshotUsageSummary( + availableBalance: response.data.availableBalance, + voucherBalance: response.data.voucherBalance, + cashBalance: response.data.cashBalance, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index d42077fc1..04fe9ada4 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -60,6 +60,7 @@ public enum OllamaCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["ollama.com", "www.ollama.com"] static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let defaultAllowFallbackBrowsers = true public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] @@ -369,7 +370,11 @@ public struct OllamaUsageFetcher: Sendable { return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")] } #if os(macOS) - let sessions = try OllamaCookieImporter.importSessions(browserDetection: self.browserDetection, logger: logger) + let sessions = try OllamaCookieImporter.importSessions( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) return sessions.map { session in CookieCandidate(cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) } @@ -451,7 +456,11 @@ public struct OllamaUsageFetcher: Sendable { return manualHeader } #if os(macOS) - let session = try OllamaCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger) + let session = try OllamaCookieImporter.importSession( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) logger?("[ollama] Using cookies from \(session.sourceLabel)") return session.cookieHeader #else @@ -509,13 +518,10 @@ public struct OllamaUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = self.makeURLSession(diagnostics) - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw OllamaUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: request) let responseInfo = ResponseInfo( statusCode: httpResponse.statusCode, - url: httpResponse.url?.absoluteString ?? "unknown") + url: httpResponse.response.url?.absoluteString ?? "unknown") guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { @@ -524,7 +530,7 @@ public struct OllamaUsageFetcher: Sendable { throw OllamaUsageError.networkError("HTTP \(httpResponse.statusCode)") } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: httpResponse.data, encoding: .utf8) ?? "" return (html, responseInfo) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift new file mode 100644 index 000000000..f35db443a --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift @@ -0,0 +1,185 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct OpenAIAPICreditGrant: Decodable, Sendable { + public let grantAmount: Double? + public let usedAmount: Double? + public let expiresAt: Date? + + private enum CodingKeys: String, CodingKey { + case grantAmount = "grant_amount" + case usedAmount = "used_amount" + case expiresAt = "expires_at" + } +} + +public struct OpenAIAPICreditGrantsList: Decodable, Sendable { + public let data: [OpenAIAPICreditGrant] +} + +public struct OpenAIAPICreditGrantsResponse: Decodable, Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let grants: OpenAIAPICreditGrantsList? + + private enum CodingKeys: String, CodingKey { + case totalGranted = "total_granted" + case totalUsed = "total_used" + case totalAvailable = "total_available" + case grants + } +} + +public enum OpenAIAPICreditBalanceError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(Int) + case forbidden + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI API key." + case let .networkError(message): + "OpenAI API credit balance network error: \(message)" + case let .apiError(statusCode): + "OpenAI API credit balance error: HTTP \(statusCode)" + case .forbidden: + "OpenAI API credit balance endpoint returned HTTP 403. Use a legacy/user API key with billing access; " + + "project keys may not expose credit grants." + case let .parseFailed(message): + "Failed to parse OpenAI API credit balance: \(message)" + } + } +} + +public struct OpenAIAPICreditBalanceSnapshot: Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let nextGrantExpiry: Date? + public let updatedAt: Date + + public init( + totalGranted: Double, + totalUsed: Double, + totalAvailable: Double, + nextGrantExpiry: Date?, + updatedAt: Date = Date()) + { + self.totalGranted = totalGranted + self.totalUsed = totalUsed + self.totalAvailable = totalAvailable + self.nextGrantExpiry = nextGrantExpiry + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double = if self.totalGranted > 0 { + min(100, max(0, (self.totalUsed / self.totalGranted) * 100)) + } else { + self.totalAvailable > 0 ? 0 : 100 + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextGrantExpiry, + resetDescription: "\(Self.formatUSD(self.totalAvailable)) available") + + return UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: max(0, self.totalUsed), + limit: max(0, self.totalGranted), + currencyCode: "USD", + period: "API credits", + resetsAt: self.nextGrantExpiry, + updatedAt: self.updatedAt), + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API balance: \(Self.formatUSD(self.totalAvailable))")) + } + + private static func formatUSD(_ value: Double) -> String { + UsageFormatter.currencyString(max(0, value), currencyCode: "USD") + } +} + +public enum OpenAIAPICreditBalanceFetcher { + public static let creditGrantsURL = URL(string: "https://api.openai.com/v1/dashboard/billing/credit_grants")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchBalance( + apiKey: String, + url: URL = Self.creditGrantsURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OpenAIAPICreditBalanceSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPICreditBalanceError.missingCredentials + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OpenAIAPICreditBalanceError.networkError(error.localizedDescription) + } + + guard response.statusCode != 403 else { + throw OpenAIAPICreditBalanceError.forbidden + } + guard response.statusCode == 200 else { + throw OpenAIAPICreditBalanceError.apiError(response.statusCode) + } + + return try self.parseSnapshot(response.data, now: now) + } + + public static func _parseSnapshotForTesting( + _ data: Data, + now: Date = Date()) throws -> OpenAIAPICreditBalanceSnapshot + { + try self.parseSnapshot(data, now: now) + } + + private static func parseSnapshot(_ data: Data, now: Date) throws -> OpenAIAPICreditBalanceSnapshot { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let decoded: OpenAIAPICreditGrantsResponse + do { + decoded = try decoder.decode(OpenAIAPICreditGrantsResponse.self, from: data) + } catch { + throw OpenAIAPICreditBalanceError.parseFailed(error.localizedDescription) + } + + let nextExpiry = decoded.grants?.data + .compactMap(\.expiresAt) + .filter { $0 > now } + .min() + + return OpenAIAPICreditBalanceSnapshot( + totalGranted: decoded.totalGranted, + totalUsed: decoded.totalUsed, + totalAvailable: decoded.totalAvailable, + nextGrantExpiry: nextExpiry, + updatedAt: now) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift new file mode 100644 index 000000000..41de76a82 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -0,0 +1,99 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum OpenAIAPIProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .openai, + metadata: ProviderMetadata( + id: .openai, + displayName: "OpenAI", + sessionLabel: "Spend", + weeklyLabel: "Requests", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show OpenAI usage", + cliName: "openai", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://platform.openai.com/usage", + statusPageURL: "https://status.openai.com"), + branding: ProviderBranding( + iconStyle: .openai, + iconResourceName: "ProviderIcon-codex", + color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenAIAPIBalanceFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "openai", + aliases: ["openai-api"], + versionDetector: nil)) + } +} + +struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { + let id: String = "openai.api.balance" + let kind: ProviderFetchKind = .apiToken + let usageFetcher: @Sendable (String) async throws -> OpenAIAPIUsageSnapshot + let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot + + init( + usageFetcher: @escaping @Sendable (String) async throws -> OpenAIAPIUsageSnapshot = { apiKey in + try await OpenAIAPIUsageFetcher.fetchUsage(apiKey: apiKey) + }, + balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in + try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey) + }) + { + self.usageFetcher = usageFetcher + self.balanceFetcher = balanceFetcher + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw OpenAIAPISettingsError.missingToken + } + + do { + let usage = try await self.usageFetcher(apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "admin-api") + } catch { + let usageError = error + // Preserve the older balance-only path for project/user keys and admin API outages. + do { + let balance = try await self.balanceFetcher(apiKey) + return self.makeResult( + usage: balance.toUsageSnapshot(), + sourceLabel: "billing-api") + } catch { + if (usageError as? OpenAIAPIUsageError)?.isCredentialRejected != true { + throw usageError + } + throw error + } + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.openAIAPIToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift new file mode 100644 index 000000000..db03e8feb --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift @@ -0,0 +1,44 @@ +import Foundation + +public enum OpenAIAPISettingsReader { + public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY" + public static let apiKeyEnvironmentKey = "OPENAI_API_KEY" + public static let apiKeyEnvironmentKeys = [ + Self.adminAPIKeyEnvironmentKey, + Self.apiKeyEnvironmentKey, + ] + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return nil + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum OpenAIAPISettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "OpenAI API key not configured. Set OPENAI_API_KEY or configure an API key in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift new file mode 100644 index 000000000..f30df0986 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -0,0 +1,435 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum OpenAIAPIUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(endpoint: String, statusCode: Int) + case parseFailed(endpoint: String, message: String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI Admin API key." + case let .networkError(message): + "OpenAI API usage network error: \(message)" + case let .apiError(endpoint, statusCode): + "OpenAI API usage \(endpoint) error: HTTP \(statusCode)" + case let .parseFailed(endpoint, message): + "Failed to parse OpenAI API usage \(endpoint): \(message)" + } + } + + var isCredentialRejected: Bool { + switch self { + case let .apiError(_, statusCode): + statusCode == 401 || statusCode == 403 + default: + false + } + } +} + +public enum OpenAIAPIUsageFetcher { + public static let organizationCostsURL = URL(string: "https://api.openai.com/v1/organization/costs")! + public static let organizationCompletionsUsageURL = + URL(string: "https://api.openai.com/v1/organization/usage/completions")! + + private static let timeoutSeconds: TimeInterval = 20 + private static let maxDailyBuckets = 31 + + public static func fetchUsage( + apiKey: String, + costsURL: URL = Self.organizationCostsURL, + completionsURL: URL = Self.organizationCompletionsUsageURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OpenAIAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPIUsageError.missingCredentials + } + + let calendar = Self.utcCalendar + let range = Self.dailyRange(now: now, calendar: calendar) + let costs = try await Self.fetchCosts( + apiKey: trimmed, + baseURL: costsURL, + range: range, + transport: transport) + let completions = try await Self.fetchCompletions( + apiKey: trimmed, + baseURL: completionsURL, + range: range, + transport: transport) + + return Self.makeSnapshot( + costs: costs, + completions: completions, + now: now, + calendar: calendar) + } + + static func _parseSnapshotForTesting( + costs: Data, + completions: Data, + now: Date, + calendar: Calendar = Self.utcCalendar) 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) + } + + private static func fetchCosts( + apiKey: String, + baseURL: URL, + range: DateRange, + transport: any ProviderHTTPTransport) async throws -> CostsResponse + { + 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) + return try Self.decodeCosts(data) + } + + private static func fetchCompletions( + apiKey: String, + baseURL: URL, + range: DateRange, + transport: any ProviderHTTPTransport) async throws -> CompletionsUsageResponse + { + let url = Self.url( + baseURL: baseURL, + range: range, + queryItems: [ + URLQueryItem(name: "group_by", value: "model"), + ]) + let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "completions", transport: transport) + return try Self.decodeCompletions(data) + } + + private static func fetchData( + url: URL, + apiKey: String, + endpoint: String, + transport: any ProviderHTTPTransport) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OpenAIAPIUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + throw OpenAIAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode) + } + return response.data + } + + private static func decodeCosts(_ data: Data) throws -> CostsResponse { + do { + return try JSONDecoder().decode(CostsResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: error.localizedDescription) + } + } + + private static func decodeCompletions(_ data: Data) throws -> CompletionsUsageResponse { + do { + return try JSONDecoder().decode(CompletionsUsageResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "completions", message: error.localizedDescription) + } + } + + private static func makeSnapshot( + costs: CostsResponse, + completions: CompletionsUsageResponse, + now: Date, + calendar: Calendar) -> OpenAIAPIUsageSnapshot + { + var accumulators: [Int: DailyAccumulator] = [:] + + for bucket in costs.data { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let value = result.amount?.value ?? 0 + accumulator.costUSD += value + let lineItem = Self.displayName(result.lineItem, fallback: "API") + accumulator.lineItems[lineItem, default: 0] += value + } + accumulators[bucket.startTime] = accumulator + } + + for bucket in completions.data { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let input = result.inputTokens ?? 0 + let cached = result.inputCachedTokens ?? 0 + let output = result.outputTokens ?? 0 + let audioInput = result.inputAudioTokens ?? 0 + let audioOutput = result.outputAudioTokens ?? 0 + let requests = result.numModelRequests ?? 0 + let totalTokens = input + output + audioInput + audioOutput + accumulator.requests += requests + accumulator.inputTokens += input + audioInput + accumulator.cachedInputTokens += cached + accumulator.outputTokens += output + audioOutput + accumulator.totalTokens += totalTokens + let modelName = Self.displayName(result.model, fallback: "Responses and Chat Completions") + accumulator.models[modelName, default: ModelAccumulator()].add( + requests: requests, + inputTokens: input + audioInput, + cachedInputTokens: cached, + outputTokens: output + audioOutput, + totalTokens: totalTokens) + } + accumulators[bucket.startTime] = accumulator + } + + let daily = accumulators.values + .filter { $0.startDate <= now } + .sorted { $0.startTime < $1.startTime } + .map { $0.makeBucket(calendar: calendar) } + return OpenAIAPIUsageSnapshot(daily: daily, updatedAt: now) + } + + private static func displayName(_ raw: String?, fallback: String) -> String { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return fallback + } + return trimmed + } + + private static func url(baseURL: URL, range: DateRange, queryItems extraItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "start_time", value: String(range.startTime)), + URLQueryItem(name: "end_time", value: String(range.endTime)), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: String(Self.maxDailyBuckets)), + ] + extraItems + return components.url! + } + + private static func dailyRange(now: Date, calendar: Calendar) -> DateRange { + let today = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(Self.maxDailyBuckets - 1), to: today) ?? today + let end = calendar.date(byAdding: .day, value: 1, to: today) ?? now + return DateRange(startTime: Int(start.timeIntervalSince1970), endTime: Int(end.timeIntervalSince1970)) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private struct DateRange { + let startTime: Int + let endTime: Int +} + +private struct DailyAccumulator { + let startTime: Int + let endTime: Int + var costUSD: Double = 0 + var requests: Int = 0 + var inputTokens: Int = 0 + var cachedInputTokens: Int = 0 + var outputTokens: Int = 0 + var totalTokens: Int = 0 + var lineItems: [String: Double] = [:] + var models: [String: ModelAccumulator] = [:] + + var startDate: Date { + Date(timeIntervalSince1970: TimeInterval(self.startTime)) + } + + func makeBucket(calendar: Calendar) -> OpenAIAPIUsageSnapshot.DailyBucket { + OpenAIAPIUsageSnapshot.DailyBucket( + day: Self.dayKey(from: self.startDate, calendar: calendar), + startTime: self.startDate, + endTime: Date(timeIntervalSince1970: TimeInterval(self.endTime)), + costUSD: self.costUSD, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens, + lineItems: self.lineItems + .map { OpenAIAPIUsageSnapshot.LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + }, + models: self.models + .map { $0.value.makeModel(name: $0.key) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } + + private static func dayKey(from date: Date, calendar: Calendar) -> String { + let comps = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + } +} + +private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add( + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.requests += requests + self.inputTokens += inputTokens + self.cachedInputTokens += cachedInputTokens + self.outputTokens += outputTokens + self.totalTokens += totalTokens + } + + func makeModel(name: String) -> OpenAIAPIUsageSnapshot.ModelBreakdown { + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + 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/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift new file mode 100644 index 000000000..07f5f034c --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -0,0 +1,222 @@ +import Foundation + +public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let day: String + public let startTime: Date + public let endTime: Date + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + public let lineItems: [LineItemBreakdown] + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public init( + day: String, + startTime: Date, + endTime: Date, + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int, + lineItems: [LineItemBreakdown], + models: [ModelBreakdown]) + { + self.day = day + self.startTime = startTime + self.endTime = endTime + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.lineItems = lineItems + self.models = models + } + } + + public struct LineItemBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let costUSD: Double + + public var id: String { + self.name + } + + public init(name: String, costUSD: Double) { + self.name = name + self.costUSD = costUSD + } + } + + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public var id: String { + self.name + } + + public init( + name: String, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.name = name + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public struct Summary: Equatable, Sendable { + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public init( + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public let daily: [DailyBucket] + public let updatedAt: Date + + public init(daily: [DailyBucket], updatedAt: Date) { + self.daily = daily.sorted { $0.startTime < $1.startTime } + self.updatedAt = updatedAt + } + + public var last30Days: Summary { + self.summary(days: 30) + } + + public var last7Days: Summary { + self.summary(days: 7) + } + + public var latestDay: Summary { + self.summary(days: 1) + } + + public func summary(days: Int) -> Summary { + let selected = self.daily.suffix(max(1, days)) + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + requests: selected.reduce(0) { $0 + $1.requests }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + + public var topModels: [ModelBreakdown] { + var totals: [String: ModelAccumulator] = [:] + for day in self.daily { + for model in day.models { + totals[model.name, default: ModelAccumulator()].add(model) + } + } + return totals + .map { name, total in total.makeModel(name: name) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + } + } + + public var topLineItems: [LineItemBreakdown] { + var totals: [String: Double] = [:] + for day in self.daily { + for item in day.lineItems { + totals[item.name, default: 0] += item.costUSD + } + } + return totals + .map { LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + } + } + + public func toUsageSnapshot() -> UsageSnapshot { + let total = self.last30Days + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: total.costUSD, + limit: 0, + currencyCode: "USD", + period: "Last 30 days", + updatedAt: self.updatedAt), + openAIAPIUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Admin API")) + } + + private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add(_ model: ModelBreakdown) { + self.requests += model.requests + self.inputTokens += model.inputTokens + self.cachedInputTokens += model.cachedInputTokens + self.outputTokens += model.outputTokens + self.totalTokens += model.totalTokens + } + + func makeModel(name: String) -> ModelBreakdown { + ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 0946a4c4f..bb5a01739 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -84,7 +84,7 @@ public struct OpenCodeUsageFetcher: Sendable { timeout: TimeInterval, now: Date = Date(), workspaceIDOverride: String? = nil, - session: URLSession = .shared) async throws -> OpenCodeUsageSnapshot + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> OpenCodeUsageSnapshot { guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { throw OpenCodeUsageError.invalidCredentials @@ -95,20 +95,20 @@ public struct OpenCodeUsageFetcher: Sendable { try await self.fetchWorkspaceID( cookieHeader: requestCookieHeader, timeout: timeout, - session: session) + transport: transport) } let subscriptionText = try await self.fetchSubscriptionInfo( workspaceID: workspaceID, cookieHeader: requestCookieHeader, timeout: timeout, - session: session) + transport: transport) return try self.parseSubscription(text: subscriptionText, now: now) } private static func fetchWorkspaceID( cookieHeader: String, timeout: TimeInterval, - session: URLSession) async throws -> String + transport: any ProviderHTTPTransport) async throws -> String { let text = try await self.fetchServerText( request: ServerRequest( @@ -118,7 +118,7 @@ public struct OpenCodeUsageFetcher: Sendable { referer: self.baseURL), cookieHeader: cookieHeader, timeout: timeout, - session: session) + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -136,7 +136,7 @@ public struct OpenCodeUsageFetcher: Sendable { referer: self.baseURL), cookieHeader: cookieHeader, timeout: timeout, - session: session) + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -157,7 +157,7 @@ public struct OpenCodeUsageFetcher: Sendable { workspaceID: String, cookieHeader: String, timeout: TimeInterval, - session: URLSession) async throws -> String + transport: any ProviderHTTPTransport) async throws -> String { let referer = URL(string: "https://opencode.ai/workspace/\(workspaceID)/billing") ?? self.baseURL let text = try await self.fetchServerText( @@ -168,7 +168,7 @@ public struct OpenCodeUsageFetcher: Sendable { referer: referer), cookieHeader: cookieHeader, timeout: timeout, - session: session) + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -190,7 +190,7 @@ public struct OpenCodeUsageFetcher: Sendable { referer: referer), cookieHeader: cookieHeader, timeout: timeout, - session: session) + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -249,7 +249,7 @@ public struct OpenCodeUsageFetcher: Sendable { request serverRequest: ServerRequest, cookieHeader: String, timeout: TimeInterval, - session: URLSession) async throws -> String + transport: any ProviderHTTPTransport) async throws -> String { let url = self.serverRequestURL( serverID: serverRequest.serverID, @@ -273,28 +273,33 @@ public struct OpenCodeUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await session.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: urlRequest) + } catch let error as URLError where error.code == .badServerResponse { throw OpenCodeUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let bodyText = String(data: data, encoding: .utf8) ?? "" - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "unknown" - Self.log.error("OpenCode returned \(httpResponse.statusCode) (type=\(contentType) length=\(data.count))") + guard response.statusCode == 200 else { + let bodyText = String(data: response.data, encoding: .utf8) ?? "" + let contentType = response.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + Self.log + .error("OpenCode returned \(response.statusCode) (type=\(contentType) length=\(response.data.count))") if self.looksSignedOut(text: bodyText) { throw OpenCodeUsageError.invalidCredentials } - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if response.statusCode == 401 || response.statusCode == 403 { throw OpenCodeUsageError.invalidCredentials } if let message = self.extractServerErrorMessage(from: bodyText) { - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode): \(message)") } - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode)") } - guard let text = String(data: data, encoding: .utf8) else { + guard let text = String(data: response.data, encoding: .utf8) else { throw OpenCodeUsageError.parseFailed("Response was not UTF-8.") } return text diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift index cafcf5b14..5d239b08d 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift @@ -33,7 +33,9 @@ enum OpenCodeWebCookieSupport { { return header } - let session = try OpenCodeCookieImporter.importSession(browserDetection: context.browserDetection) + let session = try OpenCodeCookieImporter.importSession( + browserDetection: context.browserDetection, + preferredBrowsers: self.automaticImportOrder(provider: context.provider)) guard let header = self.requestCookieHeader(from: session.cookieHeader) else { throw missingCookie() } @@ -46,4 +48,15 @@ enum OpenCodeWebCookieSupport { throw missingCookie() #endif } + + #if os(macOS) + static func automaticImportOrder(provider: UsageProvider) -> BrowserCookieImportOrder { + if provider == .opencodego, + let order = ProviderDefaults.metadata[provider]?.browserCookieOrder + { + return order + } + return [.chrome] + } + #endif } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index f19f3b824..c5ed2a77f 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -58,7 +58,8 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( cookieHeader: cookieHeader, timeout: context.webTimeout, - workspaceIDOverride: workspaceOverride) + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "web") @@ -69,7 +70,8 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( cookieHeader: cookieHeader, timeout: context.webTimeout, - workspaceIDOverride: workspaceOverride) + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "web") diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index fbee8eec8..f3cae18f4 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -33,6 +33,25 @@ public struct OpenCodeGoUsageFetcher: Sendable { "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" + private final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + guard OpenCodeGoUsageFetcher.allowsRedirect( + from: task.originalRequest?.url, + to: request.url) + else { + completionHandler(nil) + return + } + completionHandler(request) + } + } + private struct ServerRequest { let serverID: String let args: String? @@ -73,14 +92,25 @@ public struct OpenCodeGoUsageFetcher: Sendable { "renewAt", "renew_at", ] + private static let redirectGuardDelegate = RedirectGuardDelegate() + private static let redirectGuardSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + return URLSession( + configuration: configuration, + delegate: OpenCodeGoUsageFetcher.redirectGuardDelegate, + delegateQueue: nil) + }() public static func fetchUsage( cookieHeader: String, timeout: TimeInterval, now: Date = Date(), workspaceIDOverride: String? = nil, - session: URLSession = .shared) async throws -> OpenCodeGoUsageSnapshot + includeZenBalance: Bool = true, + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot { + let session = session ?? self.redirectGuardSession guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { throw OpenCodeGoUsageError.invalidCredentials } @@ -92,12 +122,47 @@ public struct OpenCodeGoUsageFetcher: Sendable { timeout: timeout, session: session) } - let subscriptionText = try await self.fetchUsagePage( - workspaceID: workspaceID, - cookieHeader: requestCookieHeader, - timeout: timeout, - session: session) - return try self.parseSubscription(text: subscriptionText, now: now) + let subscriptionText: String + do { + subscriptionText = try await self.fetchUsagePage( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } catch { + throw error + } + let snapshot = try self.parseSubscription(text: subscriptionText, now: now) + let zenBalanceTask = includeZenBalance ? Task { + try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } : nil + guard let zenBalanceTask else { + return snapshot + } + let zenBalance = try await self.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { + guard let sourceHost = sourceURL?.host?.lowercased(), + let destinationHost = destinationURL?.host?.lowercased(), + sourceHost == destinationHost, + destinationURL?.scheme?.lowercased() == "https" + else { return false } + return true + } + + public static func dashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "\(self.baseURL.absoluteString)/workspace/\(workspaceID)/go") + else { + return self.baseURL + } + return url } private static func fetchWorkspaceID( @@ -147,7 +212,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { return ids[0] } - private static func normalizeWorkspaceID(_ raw: String?) -> String? { + static func normalizeWorkspaceID(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("wrk_"), trimmed.count > 4 { @@ -595,15 +660,14 @@ public struct OpenCodeGoUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await session.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { - throw OpenCodeGoUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: urlRequest) guard httpResponse.statusCode == 200 else { - let bodyText = String(data: data, encoding: .utf8) ?? "" - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "unknown" - Self.log.error("OpenCode Go returned \(httpResponse.statusCode) (type=\(contentType) length=\(data.count))") + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + let contentType = httpResponse.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + let dataLength = httpResponse.data.count + Self.log.error( + "OpenCode Go returned \(httpResponse.statusCode) (type=\(contentType) length=\(dataLength))") if self.looksSignedOut(text: bodyText) { throw OpenCodeGoUsageError.invalidCredentials } @@ -616,13 +680,13 @@ public struct OpenCodeGoUsageFetcher: Sendable { throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") } - guard let text = String(data: data, encoding: .utf8) else { + guard let text = String(data: httpResponse.data, encoding: .utf8) else { throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") } return text } - private static func fetchPageText( + static func fetchPageText( url: URL, cookieHeader: String, timeout: TimeInterval, @@ -637,12 +701,9 @@ public struct OpenCodeGoUsageFetcher: Sendable { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw OpenCodeGoUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: request) guard httpResponse.statusCode == 200 else { - let bodyText = String(data: data, encoding: .utf8) ?? "" + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" if self.looksSignedOut(text: bodyText) { throw OpenCodeGoUsageError.invalidCredentials } @@ -654,7 +715,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { } throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") } - guard let text = String(data: data, encoding: .utf8) else { + guard let text = String(data: httpResponse.data, encoding: .utf8) else { throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") } return text @@ -674,7 +735,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { return components?.url ?? self.serverURL } - private static func looksSignedOut(text: String) -> Bool { + static func looksSignedOut(text: String) -> Bool { let lower = text.lowercased() return lower.contains("login") || lower.contains("sign in") || diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 76411691b..98f21c228 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -8,6 +8,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { public let rollingResetInSec: Int public let weeklyResetInSec: Int public let monthlyResetInSec: Int + public let zenBalanceUSD: Double? public let updatedAt: Date public init( @@ -18,6 +19,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { rollingResetInSec: Int, weeklyResetInSec: Int, monthlyResetInSec: Int, + zenBalanceUSD: Double? = nil, updatedAt: Date) { self.hasMonthlyUsage = hasMonthlyUsage @@ -27,6 +29,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { self.rollingResetInSec = rollingResetInSec self.weeklyResetInSec = weeklyResetInSec self.monthlyResetInSec = monthlyResetInSec + self.zenBalanceUSD = zenBalanceUSD self.updatedAt = updatedAt } @@ -60,7 +63,28 @@ public struct OpenCodeGoUsageSnapshot: Sendable { primary: primary, secondary: secondary, tertiary: tertiary, + providerCost: self.zenBalanceUSD.map { + ProviderCostSnapshot( + used: $0, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: self.updatedAt) + }, updatedAt: self.updatedAt, identity: nil) } + + public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: self.hasMonthlyUsage, + rollingUsagePercent: self.rollingUsagePercent, + weeklyUsagePercent: self.weeklyUsagePercent, + monthlyUsagePercent: self.monthlyUsagePercent, + rollingResetInSec: self.rollingResetInSec, + weeklyResetInSec: self.weeklyResetInSec, + monthlyResetInSec: self.monthlyResetInSec, + zenBalanceUSD: balance, + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift new file mode 100644 index 000000000..70b013611 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift @@ -0,0 +1,86 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension OpenCodeGoUsageFetcher { + static let optionalZenBalanceTimeout: TimeInterval = 5 + static let optionalZenBalanceJoinGrace: Duration = .milliseconds(250) + + public static func zenDashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "https://opencode.ai/workspace/\(workspaceID)") + else { + return URL(string: "https://opencode.ai")! + } + return url + } + + static func fetchOptionalZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + do { + let balance = try await self.fetchZenBalance( + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + try Task.checkCancellation() + return balance + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + return nil + } + } + + static func completedOptionalZenBalance(from task: Task) async throws -> Double? { + try await withThrowingTaskGroup(of: Double?.self) { group in + group.addTask { + try await task.value + } + group.addTask { + try await Task.sleep(for: self.optionalZenBalanceJoinGrace) + return nil + } + + let result = try await group.next() + group.cancelAll() + guard let value = result else { + task.cancel() + return nil + } + if value == nil { + task.cancel() + } + return value + } + } + + static func parseZenBalance(text: String) -> Double? { + OpenCodeGoZenBalanceParser.parse(text: text) + } + + private static func fetchZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + let text = try await self.fetchPageText( + url: self.zenDashboardURL(workspaceID: workspaceID), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + return self.parseZenBalance(text: text) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift new file mode 100644 index 000000000..78b70b0d0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift @@ -0,0 +1,95 @@ +import Foundation + +enum OpenCodeGoZenBalanceParser { + static func parse(text: String) -> Double? { + if let value = self.parseJSON(text: text) { + return value + } + let localizedPattern = [ + #"(?i)(?:current\s+balance|zen\s+balance|現在の残高)"#, + #"[^$]{0,80}\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"#, + ].joined() + if let value = self.extractDollarValue(pattern: localizedPattern, text: text) { + return value + } + let nearbyPattern = #"(?i)(?:balance|残高)[\s\S]{0,120}?\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"# + return self.extractDollarValue(pattern: nearbyPattern, text: text) + } + + private static func parseJSON(text: String) -> Double? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + return self.findBalanceValue(in: object, path: []) + } + + private static func findBalanceValue(in object: Any, path: [String]) -> Double? { + if let dict = object as? [String: Any] { + for (key, value) in dict { + let nextPath = path + [key] + if self.isExplicitBalanceAmountKey(key), + let number = self.doubleValue(from: value) + { + return number + } + if let found = self.findBalanceValue(in: value, path: nextPath) { + return found + } + } + return nil + } + if let array = object as? [Any] { + for (index, value) in array.enumerated() { + if let found = self.findBalanceValue(in: value, path: path + ["[\(index)]"]) { + return found + } + } + } + return nil + } + + private static func isExplicitBalanceAmountKey(_ key: String) -> Bool { + let normalized = key + .lowercased() + .filter { $0.isLetter || $0.isNumber } + return [ + "zenbalance", + "zencurrentbalance", + "currentbalance", + "currentbalanceusd", + "balanceusd", + "usdbalance", + ].contains(normalized) + } + + private static func extractDollarValue(pattern: String, text: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Double? { + switch value { + case is Bool: + nil + case let number as Double: + number + case let number as NSNumber: + number.doubleValue + case let string as String: + Double( + string + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: "")) + default: + nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 251e2c47d..b37544dcc 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -45,11 +45,20 @@ public struct OpenRouterKeyData: Decodable, Sendable { public let limit: Double? /// Current usage public let usage: Double? + /// API key usage for the current UTC day. + public let usageDaily: Double? + /// API key usage for the current UTC week. + public let usageWeekly: Double? + /// API key usage for the current UTC month. + public let usageMonthly: Double? private enum CodingKeys: String, CodingKey { case rateLimit = "rate_limit" case limit case usage + case usageDaily = "usage_daily" + case usageWeekly = "usage_weekly" + case usageMonthly = "usage_monthly" } } @@ -59,6 +68,11 @@ public struct OpenRouterRateLimit: Codable, Sendable { public let requests: Int /// Interval for the rate limit (e.g., "10s", "1m") public let interval: String + + public init(requests: Int, interval: String) { + self.requests = requests + self.interval = interval + } } public enum OpenRouterKeyQuotaStatus: String, Codable, Sendable { @@ -76,6 +90,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { public let keyDataFetched: Bool public let keyLimit: Double? public let keyUsage: Double? + public let keyUsageDaily: Double? + public let keyUsageWeekly: Double? + public let keyUsageMonthly: Double? public let rateLimit: OpenRouterRateLimit? public let updatedAt: Date @@ -87,6 +104,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { keyDataFetched: Bool = false, keyLimit: Double? = nil, keyUsage: Double? = nil, + keyUsageDaily: Double? = nil, + keyUsageWeekly: Double? = nil, + keyUsageMonthly: Double? = nil, rateLimit: OpenRouterRateLimit?, updatedAt: Date) { @@ -94,9 +114,13 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { self.totalUsage = totalUsage self.balance = balance self.usedPercent = usedPercent - self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil + self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil || + keyUsageDaily != nil || keyUsageWeekly != nil || keyUsageMonthly != nil self.keyLimit = keyLimit self.keyUsage = keyUsage + self.keyUsageDaily = keyUsageDaily + self.keyUsageWeekly = keyUsageWeekly + self.keyUsageMonthly = keyUsageMonthly self.rateLimit = rateLimit self.updatedAt = updatedAt } @@ -216,21 +240,17 @@ public struct OpenRouterUsageFetcher: Sendable { let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle request.setValue(title, forHTTPHeaderField: "X-Title") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw OpenRouterUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) if Self.debugFullErrorBodiesEnabled(environment: environment), let debugBody = Self.redactedDebugResponseBody(data) { Self.log.debug("OpenRouter non-200 body (redacted): \(LogRedactor.redact(debugBody))") } - Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") - throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)") + Self.log.error("OpenRouter API returned \(response.statusCode): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(response.statusCode)") } do { @@ -252,6 +272,9 @@ public struct OpenRouterUsageFetcher: Sendable { keyDataFetched: keyFetch.fetched, keyLimit: keyFetch.data?.limit, keyUsage: keyFetch.data?.usage, + keyUsageDaily: keyFetch.data?.usageDaily, + keyUsageWeekly: keyFetch.data?.usageWeekly, + keyUsageMonthly: keyFetch.data?.usageMonthly, rateLimit: keyFetch.data?.rateLimit, updatedAt: Date()) } catch let error as DecodingError { @@ -323,16 +346,13 @@ public struct OpenRouterUsageFetcher: Sendable { request.timeoutInterval = timeoutSeconds do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { return OpenRouterKeyFetchResult(data: nil, fetched: false) } let decoder = JSONDecoder() - let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data) + let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: response.data) return OpenRouterKeyFetchResult(data: keyResponse.data, fetched: true) } catch { Self.log.debug("Failed to fetch OpenRouter /key enrichment: \(error.localizedDescription)") diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift index 2a9fe5211..84e735e26 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -43,19 +43,17 @@ public struct PerplexityUsageFetcher: Sendable { "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw PerplexityAPIError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let statusCode = response.statusCode let body = String(data: data, encoding: .utf8) ?? "" let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body - Self.log.error("Perplexity API returned \(httpResponse.statusCode): \(truncated)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + Self.log.error("Perplexity API returned \(statusCode): \(truncated)") + if statusCode == 401 || statusCode == 403 { throw PerplexityAPIError.invalidToken } - throw PerplexityAPIError.apiError("HTTP \(httpResponse.statusCode)") + throw PerplexityAPIError.apiError("HTTP \(statusCode)") } do { diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d98204b8e..42809bbd3 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -54,6 +54,7 @@ public enum ProviderDescriptorRegistry { private static let store = Store() private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, + .openai: OpenAIAPIProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, @@ -65,6 +66,7 @@ public enum ProviderDescriptorRegistry { .copilot: CopilotProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, + .manus: ManusProviderDescriptor.descriptor, .kimi: KimiProviderDescriptor.descriptor, .kilo: KiloProviderDescriptor.descriptor, .kiro: KiroProviderDescriptor.descriptor, @@ -72,14 +74,28 @@ public enum ProviderDescriptorRegistry { .augment: AugmentProviderDescriptor.descriptor, .jetbrains: JetBrainsProviderDescriptor.descriptor, .kimik2: KimiK2ProviderDescriptor.descriptor, + .moonshot: MoonshotProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, .ollama: OllamaProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, + .elevenlabs: ElevenLabsProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, .perplexity: PerplexityProviderDescriptor.descriptor, + .mimo: MiMoProviderDescriptor.descriptor, + .doubao: DoubaoProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, .mistral: MistralProviderDescriptor.descriptor, + .deepseek: DeepSeekProviderDescriptor.descriptor, + .codebuff: CodebuffProviderDescriptor.descriptor, + .crof: CrofProviderDescriptor.descriptor, + .venice: VeniceProviderDescriptor.descriptor, + .commandcode: CommandCodeProviderDescriptor.descriptor, + .stepfun: StepFunProviderDescriptor.descriptor, + .bedrock: BedrockProviderDescriptor.descriptor, + .grok: GrokProviderDescriptor.descriptor, + .deepgram: DeepgramProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index ac94c6db4..a4c96a591 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -18,9 +18,12 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { } public struct ProviderFetchContext: Sendable { + public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode public let includeCredits: Bool + public let includeOptionalUsage: Bool public let webTimeout: TimeInterval public let webDebugDumpHTML: Bool public let verbose: Bool @@ -29,11 +32,14 @@ public struct ProviderFetchContext: Sendable { public let fetcher: UsageFetcher public let claudeFetcher: any ClaudeUsageFetching public let browserDetection: BrowserDetection + public let selectedTokenAccountID: UUID? + public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? public init( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, includeCredits: Bool, + includeOptionalUsage: Bool = true, webTimeout: TimeInterval, webDebugDumpHTML: Bool, verbose: Bool, @@ -41,11 +47,14 @@ public struct ProviderFetchContext: Sendable { settings: ProviderSettingsSnapshot?, fetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + selectedTokenAccountID: UUID? = nil, + tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil) { self.runtime = runtime self.sourceMode = sourceMode self.includeCredits = includeCredits + self.includeOptionalUsage = includeOptionalUsage self.webTimeout = webTimeout self.webDebugDumpHTML = webDebugDumpHTML self.verbose = verbose @@ -54,6 +63,8 @@ public struct ProviderFetchContext: Sendable { self.fetcher = fetcher self.claudeFetcher = claudeFetcher self.browserDetection = browserDetection + self.selectedTokenAccountID = selectedTokenAccountID + self.tokenAccountTokenUpdater = tokenAccountTokenUpdater } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 2692c4920..c210fce09 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -12,17 +12,22 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: AlibabaCodingPlanProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, + manus: ManusProviderSettings? = nil, zai: ZaiProviderSettings? = nil, copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, abacus: AbacusProviderSettings? = nil, - mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -35,17 +40,22 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: alibaba, factory: factory, minimax: minimax, + manus: manus, zai: zai, copilot: copilot, kilo: kilo, kimi: kimi, augment: augment, + moonshot: moonshot, amp: amp, ollama: ollama, jetbrains: jetbrains, + windsurf: windsurf, perplexity: perplexity, + mimo: mimo, abacus: abacus, - mistral: mistral) + mistral: mistral, + stepfun: stepfun) } public struct CodexProviderSettings: Sendable { @@ -78,17 +88,20 @@ public struct ProviderSettingsSnapshot: Sendable { public let webExtrasEnabled: Bool public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let organizationID: String? public init( usageDataSource: ClaudeUsageDataSource, webExtrasEnabled: Bool, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + organizationID: String? = nil) { self.usageDataSource = usageDataSource self.webExtrasEnabled = webExtrasEnabled self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.organizationID = organizationID } } @@ -156,6 +169,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ManusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct ZaiProviderSettings: Sendable { public let apiRegion: ZaiAPIRegion @@ -165,7 +188,13 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CopilotProviderSettings: Sendable { - public init() {} + public let apiToken: String? + public let enterpriseHost: String? + + public init(apiToken: String? = nil, enterpriseHost: String? = nil) { + self.apiToken = apiToken + self.enterpriseHost = enterpriseHost + } } public struct KiloProviderSettings: Sendable { @@ -198,6 +227,14 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MoonshotProviderSettings: Sendable { + public let region: MoonshotRegion? + + public init(region: MoonshotRegion? = nil) { + self.region = region + } + } + public struct JetBrainsProviderSettings: Sendable { public let ideBasePath: String? @@ -216,6 +253,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct CommandCodeProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct OllamaProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -226,6 +273,22 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct WindsurfProviderSettings: Sendable { + public let usageDataSource: WindsurfUsageDataSource + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init( + usageDataSource: WindsurfUsageDataSource, + cookieSource: ProviderCookieSource, + manualCookieHeader: String?) + { + self.usageDataSource = usageDataSource + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct PerplexityProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -236,6 +299,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MiMoProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct AbacusProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -256,6 +329,25 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct StepFunProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualToken: String + public let username: String + public let password: String + + public init( + cookieSource: ProviderCookieSource = .auto, + manualToken: String = "", + username: String = "", + password: String = "") + { + self.cookieSource = cookieSource + self.manualToken = manualToken + self.username = username + self.password = password + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -266,17 +358,23 @@ public struct ProviderSettingsSnapshot: Sendable { public let alibaba: AlibabaCodingPlanProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? + public let manus: ManusProviderSettings? public let zai: ZaiProviderSettings? public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? + public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? + public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let windsurf: WindsurfProviderSettings? public let perplexity: PerplexityProviderSettings? + public let mimo: MiMoProviderSettings? public let abacus: AbacusProviderSettings? public let mistral: MistralProviderSettings? + public let stepfun: StepFunProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -293,17 +391,23 @@ public struct ProviderSettingsSnapshot: Sendable { alibaba: AlibabaCodingPlanProviderSettings?, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, + manus: ManusProviderSettings?, zai: ZaiProviderSettings?, copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, + commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, abacus: AbacusProviderSettings? = nil, - mistral: MistralProviderSettings? = nil) + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -315,17 +419,23 @@ public struct ProviderSettingsSnapshot: Sendable { self.alibaba = alibaba self.factory = factory self.minimax = minimax + self.manus = manus self.zai = zai self.copilot = copilot self.kilo = kilo self.kimi = kimi self.augment = augment + self.moonshot = moonshot self.amp = amp + self.commandcode = commandcode self.ollama = ollama self.jetbrains = jetbrains + self.windsurf = windsurf self.perplexity = perplexity + self.mimo = mimo self.abacus = abacus self.mistral = mistral + self.stepfun = stepfun } } @@ -338,17 +448,23 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) + case manus(ProviderSettingsSnapshot.ManusProviderSettings) case zai(ProviderSettingsSnapshot.ZaiProviderSettings) case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) + case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case windsurf(ProviderSettingsSnapshot.WindsurfProviderSettings) case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) + case mimo(ProviderSettingsSnapshot.MiMoProviderSettings) case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) case mistral(ProviderSettingsSnapshot.MistralProviderSettings) + case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -362,23 +478,30 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? + public var manus: ProviderSettingsSnapshot.ManusProviderSettings? public var zai: ProviderSettingsSnapshot.ZaiProviderSettings? public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? + public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var windsurf: ProviderSettingsSnapshot.WindsurfProviderSettings? public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? + public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings? public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? public var mistral: ProviderSettingsSnapshot.MistralProviderSettings? + public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive } + // swiftlint:disable:next cyclomatic_complexity public mutating func apply(_ contribution: ProviderSettingsSnapshotContribution) { switch contribution { case let .codex(value): self.codex = value @@ -389,17 +512,23 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .alibaba(value): self.alibaba = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value + case let .manus(value): self.manus = value case let .zai(value): self.zai = value case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value + case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value + case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .windsurf(value): self.windsurf = value case let .perplexity(value): self.perplexity = value + case let .mimo(value): self.mimo = value case let .abacus(value): self.abacus = value case let .mistral(value): self.mistral = value + case let .stepfun(value): self.stepfun = value } } @@ -415,16 +544,22 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { alibaba: self.alibaba, factory: self.factory, minimax: self.minimax, + manus: self.manus, zai: self.zai, copilot: self.copilot, kilo: self.kilo, kimi: self.kimi, augment: self.augment, + moonshot: self.moonshot, amp: self.amp, + commandcode: self.commandcode, ollama: self.ollama, jetbrains: self.jetbrains, + windsurf: self.windsurf, perplexity: self.perplexity, + mimo: self.mimo, abacus: self.abacus, - mistral: self.mistral) + mistral: self.mistral, + stepfun: self.stepfun) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 85113cc26..c59a9d2ec 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -26,6 +26,18 @@ public enum ProviderTokenResolver { self.syntheticResolution(environment: environment)?.token } + public static func openAIAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.openAIAPIResolution(environment: environment)?.token + } + + public static func claudeAdminAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.claudeAdminAPIResolution(environment: environment)?.token + } + public static func copilotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.copilotResolution(environment: environment)?.token } @@ -50,6 +62,10 @@ public enum ProviderTokenResolver { self.kimiK2Resolution(environment: environment)?.token } + public static func moonshotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.moonshotResolution(environment: environment)?.token + } + public static func kiloToken( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> String? @@ -65,12 +81,95 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func elevenLabsToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.elevenLabsResolution(environment: environment)?.token + } + public static func perplexitySessionToken( environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.perplexityResolution(environment: environment)?.token } + public static func deepseekToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.deepseekResolution(environment: environment)?.token + } + + public static func crofToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.crofResolution(environment: environment)?.token + } + + public static func veniceToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.veniceResolution(environment: environment)?.token + } + + public static func stepfunToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.stepfunResolution(environment: environment)?.token + } + + public static func doubaoToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.doubaoResolution(environment: environment)?.token + } + + public static func bedrockAccessKeyID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.bedrockResolution(environment: environment)?.token + } + + public static func bedrockResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(BedrockSettingsReader.accessKeyID(environment: environment)) + } + + public static func deepseekResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) + } + + public static func crofResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(CrofSettingsReader.apiKey(environment: environment)) + } + + public static func veniceResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(VeniceSettingsReader.apiKey(environment: environment)) + } + + public static func codebuffToken( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> String? + { + self.codebuffResolution(environment: environment, authFileURL: authFileURL)?.token + } + + public static func stepfunResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(StepFunSettingsReader.token(environment: environment)) + } + + public static func doubaoResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -83,6 +182,18 @@ public enum ProviderTokenResolver { self.resolveEnv(SyntheticSettingsReader.apiKey(environment: environment)) } + public static func openAIAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OpenAIAPISettingsReader.apiKey(environment: environment)) + } + + public static func claudeAdminAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ClaudeAdminAPISettingsReader.apiKey(environment: environment)) + } + public static func copilotResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -132,6 +243,12 @@ public enum ProviderTokenResolver { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func moonshotResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(MoonshotSettingsReader.apiKey(environment: environment)) + } + public static func kiloResolution( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> ProviderTokenResolution? @@ -157,6 +274,43 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func elevenLabsResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ElevenLabsSettingsReader.apiKey(environment: environment)) + } + + public enum DeepgramCredentialKind: Sendable { + case apiKey + case projectID + } + + public static func deepgramResolution( + type: DeepgramCredentialKind, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + switch type { + case .apiKey: + self.resolveEnv(DeepgramSettingsReader.apiKey(environment: environment))?.token + + case .projectID: + self.resolveEnv(DeepgramSettingsReader.projectID(environment: environment))?.token + } + } + + public static func codebuffResolution( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> ProviderTokenResolution? + { + if let resolution = self.resolveEnv(CodebuffSettingsReader.apiKey(environment: environment)) { + return resolution + } + if let token = CodebuffSettingsReader.authToken(authFileURL: authFileURL) { + return ProviderTokenResolution(token: token, source: .authFile) + } + return nil + } + public static func perplexityResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 22493fa0f..e487029c6 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -4,6 +4,7 @@ import SweetCookieKit // swiftformat:disable sortDeclarations public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex + case openai case claude case cursor case opencode @@ -15,6 +16,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case copilot case zai case minimax + case manus case kimi case kilo case kiro @@ -22,23 +24,39 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case augment case jetbrains case kimik2 + case moonshot case amp case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf case perplexity + case mimo + case doubao case abacus case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case deepgram } // swiftformat:enable sortDeclarations public enum IconStyle: Sendable, CaseIterable { case codex + case openai case claude case zai case minimax + case manus case gemini case antigravity case cursor @@ -54,14 +72,28 @@ public enum IconStyle: Sendable, CaseIterable { case vertexai case augment case jetbrains + case moonshot case amp case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf case perplexity + case mimo + case doubao case abacus case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case deepgram case combined } @@ -82,6 +114,8 @@ public struct ProviderMetadata: Sendable { public let browserCookieOrder: BrowserCookieImportOrder? public let dashboardURL: String? public let subscriptionDashboardURL: String? + /// Provider-specific release notes or changelog URL for CLI/provider updates. + public let changelogURL: String? /// Statuspage.io base URL for incident polling (append /api/v2/status.json). public let statusPageURL: String? /// Browser-only status link (no API polling); used when statusPageURL is nil. @@ -106,6 +140,7 @@ public struct ProviderMetadata: Sendable { browserCookieOrder: BrowserCookieImportOrder? = nil, dashboardURL: String?, subscriptionDashboardURL: String? = nil, + changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, statusWorkspaceProductID: String? = nil) @@ -126,6 +161,7 @@ public struct ProviderMetadata: Sendable { self.browserCookieOrder = browserCookieOrder self.dashboardURL = dashboardURL self.subscriptionDashboardURL = subscriptionDashboardURL + self.changelogURL = changelogURL self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID @@ -166,4 +202,14 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Grok is normally signed in through Chrome; keep this narrow so CLI/live probes do not touch + /// unrelated browser keychains. + public static var grokCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift new file mode 100644 index 000000000..a8178d21e --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -0,0 +1,148 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum StepFunProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .stepfun, + metadata: ProviderMetadata( + id: .stepfun, + displayName: "StepFun", + sessionLabel: "5h Window", + weeklyLabel: "Weekly Window", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show StepFun usage", + cliName: "stepfun", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.stepfun.com/plan-usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .stepfun, + iconResourceName: "ProviderIcon-stepfun", + color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "StepFun per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "stepfun", + aliases: ["step-fun", "sf"], + versionDetector: nil)) + } +} + +struct StepFunWebFetchStrategy: ProviderFetchStrategy { + let id: String = "stepfun.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.settings?.stepfun?.cookieSource != .off + } + + 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) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + // MARK: - Token Resolution + + private static func resolveToken( + context: ProviderFetchContext, + allowCached: Bool) async throws -> String + { + let settings = context.settings?.stepfun + + // 1. Manual mode: use the token directly from settings + if settings?.cookieSource == .manual { + let manualToken = settings?.manualToken ?? "" + guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StepFunUsageError.missingToken + } + return StepFunTokenNormalizer.normalize(manualToken) + } + + // 2. Cached token from previous login + if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) { + return StepFunTokenNormalizer.normalize(cached.cookieHeader) + } + + // 3. Username + password from Settings UI → perform full login flow + // (register device → sign in by password → get Oasis-Token) + if let settings, !settings.username.isEmpty, !settings.password.isEmpty { + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + // 4. Direct token from env var + if let token = StepFunSettingsReader.token(environment: context.env) { + return token + } + + // 5. Username + password from env vars → perform full login flow + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + throw StepFunUsageError.missingCredentials + } +} + +// MARK: - Token Normalizer + +public enum StepFunTokenNormalizer { + /// Normalize a StepFun token value — extracts the Oasis-Token from a cookie header + /// or returns the raw token value if it's not a cookie header. + public static func normalize(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + // If it looks like a cookie header, extract Oasis-Token + if trimmed.contains("Oasis-Token=") { + let parts = trimmed.components(separatedBy: "Oasis-Token=") + if parts.count > 1 { + let afterToken = parts[1] + return afterToken.components(separatedBy: ";").first? + .trimmingCharacters(in: .whitespaces) ?? afterToken + } + } + + return trimmed + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift new file mode 100644 index 000000000..9c3227e39 --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct StepFunSettingsReader: Sendable { + public static let usernameEnvironmentKey = "STEPFUN_USERNAME" + public static let passwordEnvironmentKey = "STEPFUN_PASSWORD" + public static let tokenEnvironmentKey = "STEPFUN_TOKEN" + + public static func username( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.usernameEnvironmentKey]) + } + + public static func password( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.passwordEnvironmentKey]) + } + + public static func token( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.tokenEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift new file mode 100644 index 000000000..8fdbdc8a0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -0,0 +1,493 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +/// A flexible number type that can decode from both JSON integers and floats. +/// The StepFun API returns `five_hour_usage_left_rate: 1` (int) or `0.99781543` (float). +public struct StepFunFlexibleNumber: Decodable, Sendable { + public let value: Double + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { + self.value = Double(intVal) + } else if let doubleVal = try? container.decode(Double.self) { + self.value = doubleVal + } else { + self.value = 0 + } + } + + public init(_ value: Double) { + self.value = value + } +} + +/// A flexible timestamp type that can decode from both JSON strings and integers. +/// The StepFun API returns timestamps as strings like `"1777528800"`. +public struct StepFunFlexibleTimestamp: Decodable, Sendable { + public let value: Int64 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let strVal = try? container.decode(String.self), let parsed = Int64(strVal) { + self.value = parsed + } else if let intVal = try? container.decode(Int64.self) { + self.value = intVal + } else { + self.value = 0 + } + } + + public init(_ value: Int64) { + self.value = value + } +} + +public struct StepFunRateLimitResponse: Decodable, Sendable { + public let status: Int? + public let code: Int? + public let message: String? + public let desc: String? + public let fiveHourUsageLeftRate: StepFunFlexibleNumber? + public let weeklyUsageLeftRate: StepFunFlexibleNumber? + public let fiveHourUsageResetTime: StepFunFlexibleTimestamp? + public let weeklyUsageResetTime: StepFunFlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case status + case code + case message + case desc + case fiveHourUsageLeftRate = "five_hour_usage_left_rate" + case weeklyUsageLeftRate = "weekly_usage_left_rate" + case fiveHourUsageResetTime = "five_hour_usage_reset_time" + case weeklyUsageResetTime = "weekly_usage_reset_time" + } + + public var isSuccess: Bool { + self.status == 1 + } +} + +// MARK: - Plan status response types + +struct StepFunPlanStatusResponse: Decodable { + let status: Int? + let subscription: StepFunSubscription? + + var planName: String? { + self.subscription?.name?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct StepFunSubscription: Decodable { + let name: String? + let planType: Int? + let planStatus: Int? + + enum CodingKeys: String, CodingKey { + case name + case planType = "plan_type" + case planStatus = "status" + } +} + +// MARK: - Auth response types + +struct StepFunRegisterDeviceResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunLoginResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunTokenPair: Decodable { + let raw: String +} + +// MARK: - Domain snapshot + +public struct StepFunUsageSnapshot: Sendable { + public let fiveHourUsageLeftRate: Double + public let weeklyUsageLeftRate: Double + public let fiveHourUsageResetTime: Date + public let weeklyUsageResetTime: Date + public let planName: String? + public let updatedAt: Date + + public init( + fiveHourUsageLeftRate: Double, + weeklyUsageLeftRate: Double, + fiveHourUsageResetTime: Date, + weeklyUsageResetTime: Date, + planName: String? = nil, + updatedAt: Date) + { + self.fiveHourUsageLeftRate = fiveHourUsageLeftRate + self.weeklyUsageLeftRate = weeklyUsageLeftRate + self.fiveHourUsageResetTime = fiveHourUsageResetTime + self.weeklyUsageResetTime = weeklyUsageResetTime + self.planName = planName + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Five-hour window: primary + let fiveHourUsedPercent = max(0, min(100, (1.0 - self.fiveHourUsageLeftRate) * 100)) + let fiveHourResetDescription = UsageFormatter.resetDescription(from: self.fiveHourUsageResetTime) + let fiveHourWindow = RateWindow( + usedPercent: fiveHourUsedPercent, + windowMinutes: 300, + resetsAt: self.fiveHourUsageResetTime, + resetDescription: fiveHourResetDescription) + + // Weekly window: secondary + let weeklyUsedPercent = max(0, min(100, (1.0 - self.weeklyUsageLeftRate) * 100)) + let weeklyResetDescription = UsageFormatter.resetDescription(from: self.weeklyUsageResetTime) + let weeklyWindow = RateWindow( + usedPercent: weeklyUsedPercent, + windowMinutes: 10080, + resetsAt: self.weeklyUsageResetTime, + resetDescription: weeklyResetDescription) + + let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan + + let identity = ProviderIdentitySnapshot( + providerID: .stepfun, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: fiveHourWindow, + secondary: weeklyWindow, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum StepFunUsageError: LocalizedError, Sendable { + case missingCredentials + case missingToken + case networkError(String) + case apiError(String) + case parseFailed(String) + case loginFailed(String) + case deviceRegistrationFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing StepFun username or password. Set STEPFUN_USERNAME and STEPFUN_PASSWORD environment variables." + case .missingToken: + "Missing StepFun authentication token." + case let .networkError(message): + "StepFun network error: \(message)" + case let .apiError(message): + "StepFun API error: \(message)" + case let .parseFailed(message): + "Failed to parse StepFun response: \(message)" + case let .loginFailed(message): + "StepFun login failed: \(message)" + case let .deviceRegistrationFailed(message): + "StepFun device registration failed: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct StepFunUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.stepfunUsage) + private static let platformURL = URL(string: "https://platform.stepfun.com")! + private static let apiURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit")! + private static let planStatusURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus")! + private static let registerDeviceURL = + 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 timeoutSeconds: TimeInterval = 15 + + private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" + private static let appID = "10300" + + private static let baseHeaders: [String: String] = [ + "content-type": "application/json", + "oasis-appid": appID, + "oasis-platform": "web", + "oasis-webid": webID, + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ] + + // MARK: - Public API + + /// Perform the full login flow (username + password → Oasis-Token) and return the token. + /// Does NOT fetch usage — the caller should cache the token and then call `fetchUsage(token:)`. + public static func login(username: String, password: String) async throws -> String { + try await self.fullLogin(username: username, password: password) + } + + /// 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 { + throw StepFunUsageError.missingToken + } + return try await self.queryUsage(token: token) + } + + /// Full login flow: username + password → token, then fetch usage. + public static func fetchUsage(username: String, password: String) async throws -> StepFunUsageSnapshot { + let token = try await self.fullLogin(username: username, password: password) + return try await self.queryUsage(token: token) + } + + // MARK: - Login + + private static func fullLogin(username: String, password: String) async throws -> String { + // Step 1: Get INGRESSCOOKIE by visiting the platform homepage + let (ingressCookie, _) = try await self.getIngressCookie() + + // Step 2: RegisterDevice → get anonymous token + let anonToken = try await self.registerDevice(ingressCookie: ingressCookie) + + // Step 3: SignInByPassword → get authenticated token + return try await self.signInByPassword( + username: username, + password: password, + ingressCookie: ingressCookie, + anonToken: anonToken) + } + + private static func getIngressCookie() async throws -> (String, HTTPURLResponse) { + var request = URLRequest(url: self.platformURL) + request.httpMethod = "GET" + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let httpResponse = response.response + + // Extract INGRESSCOOKIE from Set-Cookie headers + let setCookieHeaders = httpResponse.allHeaderFields.filter { ($0.key as? String)?.lowercased() == "set-cookie" } + var ingressCookie = "" + for (_, value) in setCookieHeaders { + let cookieString = "\(value)" + if cookieString.contains("INGRESSCOOKIE=") { + let parts = cookieString.components(separatedBy: "INGRESSCOOKIE=") + if parts.count > 1 { + let valuePart = parts[1].components(separatedBy: ";").first ?? "" + ingressCookie = valuePart.trimmingCharacters(in: .whitespaces) + } + } + } + + // Also check cookies from the URLSession cookie store + if ingressCookie.isEmpty { + let cookies = HTTPCookieStorage.shared.cookies(for: self.platformURL) ?? [] + for cookie in cookies where cookie.name == "INGRESSCOOKIE" { + ingressCookie = cookie.value + break + } + } + + guard !ingressCookie.isEmpty else { + throw StepFunUsageError.loginFailed("Could not obtain INGRESSCOOKIE") + } + + return (ingressCookie, httpResponse) + } + + private static func registerDevice(ingressCookie: String) async throws -> String { + var request = URLRequest(url: self.registerDeviceURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("INGRESSCOOKIE=\(ingressCookie)", 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 RegisterDevice returned \(response.statusCode): \(body)") + throw StepFunUsageError.deviceRegistrationFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRegisterDeviceResponse + do { + decoded = try JSONDecoder().decode(StepFunRegisterDeviceResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RegisterDevice response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + 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)" + } + + private static func signInByPassword( + username: String, + password: String, + ingressCookie: String, + anonToken: String) async throws -> String + { + var request = URLRequest(url: self.loginURL) + request.httpMethod = "POST" + let body: [String: String] = ["username": username, "password": password] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue( + "Oasis-Token=\(anonToken); Oasis-Webid=\(self.webID); INGRESSCOOKIE=\(ingressCookie)", + 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 SignInByPassword returned \(response.statusCode): \(body)") + throw StepFunUsageError.loginFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunLoginResponse + do { + decoded = try JSONDecoder().decode(StepFunLoginResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("SignInByPassword response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.loginFailed("No access token in login response") + } + + let refreshToken = decoded.refreshToken?.raw ?? "" + return "\(accessToken)...\(refreshToken)" + } + + // MARK: - Query usage + + private static func queryUsage(token: String) async throws -> StepFunUsageSnapshot { + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); 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 API returned \(response.statusCode): \(body)") + throw StepFunUsageError.apiError("HTTP \(response.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("StepFun API response: \(jsonString)") + } + + var snapshot = try self.parseSnapshot(data: data) + + // Fetch plan name in parallel is not needed — just do it sequentially. + // If plan status fails, we still return usage data without plan name. + if let planName = try? await self.queryPlanStatus(token: token) { + snapshot = StepFunUsageSnapshot( + fiveHourUsageLeftRate: snapshot.fiveHourUsageLeftRate, + weeklyUsageLeftRate: snapshot.weeklyUsageLeftRate, + fiveHourUsageResetTime: snapshot.fiveHourUsageResetTime, + weeklyUsageResetTime: snapshot.weeklyUsageResetTime, + planName: planName, + updatedAt: snapshot.updatedAt) + } + + return snapshot + } + + // MARK: - Plan Status + + private static func queryPlanStatus(token: String) async throws -> String? { + var request = URLRequest(url: self.planStatusURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + Self.log.debug("StepFun plan status request failed, skipping plan name") + return nil + } + + let decoded: StepFunPlanStatusResponse + do { + decoded = try JSONDecoder().decode(StepFunPlanStatusResponse.self, from: response.data) + } catch { + Self.log.debug("StepFun plan status parse failed: \(error.localizedDescription)") + return nil + } + + return decoded.planName + } + + public static func _parseSnapshotForTesting(_ data: Data) throws -> StepFunUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> StepFunUsageSnapshot { + let decoded: StepFunRateLimitResponse + do { + decoded = try JSONDecoder().decode(StepFunRateLimitResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed(error.localizedDescription) + } + + guard decoded.isSuccess else { + let msg = decoded.message ?? decoded.code.map(String.init) ?? "unknown" + throw StepFunUsageError.apiError(msg) + } + + guard let fiveHourRate = decoded.fiveHourUsageLeftRate, + let weeklyRate = decoded.weeklyUsageLeftRate, + let fiveHourReset = decoded.fiveHourUsageResetTime, + let weeklyReset = decoded.weeklyUsageResetTime + else { + throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") + } + + return StepFunUsageSnapshot( + fiveHourUsageLeftRate: fiveHourRate.value, + weeklyUsageLeftRate: weeklyRate.value, + fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset.value)), + weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset.value)), + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index f3e2edff8..a1ff84f95 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -104,19 +104,15 @@ public struct SyntheticUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw SyntheticUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("Synthetic API returned \(httpResponse.statusCode): \(errorMessage)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + Self.log.error("Synthetic API returned \(response.statusCode): \(errorMessage)") + if response.statusCode == 401 || response.statusCode == 403 { throw SyntheticUsageError.invalidCredentials } - throw SyntheticUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + throw SyntheticUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } do { diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift new file mode 100644 index 000000000..b44db67cd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum VeniceProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .venice, + metadata: ProviderMetadata( + id: .venice, + displayName: "Venice", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Venice usage", + cliName: "venice", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://venice.ai/settings/api", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .venice, + iconResourceName: "ProviderIcon-venice", + color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Venice per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [VeniceAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "venice", + aliases: ["ven"], + versionDetector: nil)) + } +} + +struct VeniceAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "venice.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw VeniceUsageError.missingCredentials + } + let usage = try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.veniceToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift new file mode 100644 index 000000000..57386ac96 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct VeniceSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "VENICE_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "VENICE_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift new file mode 100644 index 000000000..0326a40dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift @@ -0,0 +1,236 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct VeniceBalanceResponse: Decodable, Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let balances: VeniceBalances + public let diemEpochAllocation: Double? + + enum CodingKeys: String, CodingKey { + case canConsume + case consumptionCurrency + case balances + case diemEpochAllocation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.canConsume = try container.decode(Bool.self, forKey: .canConsume) + self.consumptionCurrency = try container.decodeIfPresent(String.self, forKey: .consumptionCurrency) + self.balances = try container.decode(VeniceBalances.self, forKey: .balances) + self.diemEpochAllocation = try container.decodeFlexibleDoubleIfPresent(forKey: .diemEpochAllocation) + } +} + +public struct VeniceBalances: Decodable, Sendable { + public let diem: Double? + public let usd: Double? + + enum CodingKeys: String, CodingKey { + case diem + case usd + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.diem = try container.decodeFlexibleDoubleIfPresent(forKey: .diem) + self.usd = try container.decodeFlexibleDoubleIfPresent(forKey: .usd) + } +} + +// MARK: - Domain snapshot + +public struct VeniceUsageSnapshot: Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let diemBalance: Double? + public let usdBalance: Double? + public let diemEpochAllocation: Double? + public let updatedAt: Date + + public init( + canConsume: Bool, + consumptionCurrency: String?, + diemBalance: Double?, + usdBalance: Double?, + diemEpochAllocation: Double?, + updatedAt: Date) + { + self.canConsume = canConsume + self.consumptionCurrency = consumptionCurrency + self.diemBalance = diemBalance + self.usdBalance = usdBalance + self.diemEpochAllocation = diemEpochAllocation + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balanceDetail: String + let usedPercent: Double + let activeCurrency = self.consumptionCurrency?.uppercased() + + if !self.canConsume { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 + } else if activeCurrency == "USD", let usd = self.usdBalance, usd > 0 { + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else if activeCurrency != "USD", let diem = self.diemBalance, let allocation = self.diemEpochAllocation, + allocation > 0 + { + // DIEM balance with epoch allocation + let remaining = diem + let usedAmount = allocation - remaining + let used = clamp(usedAmount / allocation * 100, min: 0, max: 100) + usedPercent = used + let allocationStr = String(format: "%.2f", allocation) + let remainingStr = String(format: "%.2f", remaining) + balanceDetail = "DIEM \(remainingStr) / \(allocationStr) epoch allocation" + } else if activeCurrency == "DIEM", let diem = self.diemBalance, diem > 0 { + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let diem = self.diemBalance, diem > 0 { + // DIEM balance without allocation + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let usd = self.usdBalance, usd > 0 { + // USD balance + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else { + balanceDetail = "No Venice API balance available" + usedPercent = 100 + } + + let identity = ProviderIdentitySnapshot( + providerID: .venice, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + + return UsageSnapshot( + primary: balanceWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum VeniceUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Venice API key." + case let .networkError(message): + "Venice network error: \(message)" + case let .apiError(message): + "Venice API error: \(message)" + case let .parseFailed(message): + "Failed to parse Venice response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct VeniceUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.veniceUsage) + private static let balanceURL = URL(string: "https://api.venice.ai/api/v1/billing/balance")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> VeniceUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VeniceUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + 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) + guard response.statusCode == 200 else { + Self.log.error("Venice API returned \(response.statusCode)") + throw VeniceUsageError.apiError("HTTP \(response.statusCode)") + } + + return try Self.parseSnapshot(data: response.data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> VeniceUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> VeniceUsageSnapshot { + let decoded: VeniceBalanceResponse + do { + decoded = try JSONDecoder().decode(VeniceBalanceResponse.self, from: data) + } catch { + throw VeniceUsageError.parseFailed(error.localizedDescription) + } + + return VeniceUsageSnapshot( + canConsume: decoded.canConsume, + consumptionCurrency: decoded.consumptionCurrency, + diemBalance: decoded.balances.diem, + usdBalance: decoded.balances.usd, + diemEpochAllocation: decoded.diemEpochAllocation, + updatedAt: Date()) + } +} + +// MARK: - Helper + +private func clamp(_ value: Double, min: Double, max: Double) -> Double { + Swift.min(Swift.max(value, min), max) +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: K) throws -> Double? { + if try self.decodeNil(forKey: key) { + return nil + } + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + if let stringValue = try? self.decode(String.self, forKey: key) { + let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let parsed = Double(trimmed) { + return parsed + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a numeric string for \(key.stringValue), got '\(stringValue)'") + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift index f1400800a..4356d24fb 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift @@ -55,10 +55,28 @@ public enum VertexAIOAuthCredentialsError: LocalizedError, Sendable { } public enum VertexAIOAuthCredentialsStore { - private static var credentialsFilePath: URL { + #if DEBUG + @TaskLocal static var gcloudAccessTokenOverrideForTesting: (@Sendable ([String: String]) async throws -> String)? + #endif + + private struct ServiceAccountMetadata { + let email: String + let projectId: String? + } + + private static func credentialsFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let path = environment["GOOGLE_APPLICATION_CREDENTIALS"]?.trimmingCharacters( + in: .whitespacesAndNewlines), + !path.isEmpty + { + return URL(fileURLWithPath: path) + } + let home = FileManager.default.homeDirectoryForCurrentUser // gcloud application default credentials location - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -71,9 +89,11 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("application_default_credentials.json") } - private static var projectFilePath: URL { + private static func projectFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { let home = FileManager.default.homeDirectoryForCurrentUser - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -88,28 +108,88 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("config_default") } - public static func load() throws -> VertexAIOAuthCredentials { - let url = self.credentialsFilePath + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let json = try? self.parseJSONObject(data: data) + else { + return false + } + + if self.parseServiceAccountMetadata(json: json) != nil { + return true + } + + return (try? self.parseUserCredentials(json: json, environment: environment)) != nil + } + + public static func load( + environment: [String: String] = ProcessInfo.processInfo.environment) throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path) else { + throw VertexAIOAuthCredentialsError.notFound + } + + let data = try Data(contentsOf: url) + return try self.parse(data: data, environment: environment) + } + + public static func loadForFetch( + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) guard FileManager.default.fileExists(atPath: url.path) else { throw VertexAIOAuthCredentialsError.notFound } let data = try Data(contentsOf: url) - return try self.parse(data: data) + let json = try self.parseJSONObject(data: data) + if let serviceAccount = self.parseServiceAccountMetadata(json: json) { + let token = try await self.printAccessToken(environment: environment) + return VertexAIOAuthCredentials( + accessToken: token, + refreshToken: "", + clientId: "", + clientSecret: "", + projectId: serviceAccount.projectId ?? self.loadProjectId(environment: environment), + email: serviceAccount.email, + expiryDate: Date().addingTimeInterval(50 * 60)) + } + + return try self.parseUserCredentials(json: json, environment: environment) } public static func parse(data: Data) throws -> VertexAIOAuthCredentials { + try self.parse(data: data, environment: ProcessInfo.processInfo.environment) + } + + public static func parse( + data: Data, + environment: [String: String]) throws -> VertexAIOAuthCredentials + { + let json = try self.parseJSONObject(data: data) + return try self.parseUserCredentials(json: json, environment: environment) + } + + private static func parseJSONObject(data: Data) throws -> [String: Any] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw VertexAIOAuthCredentialsError.decodeFailed("Invalid JSON") } + return json + } + private static func parseUserCredentials( + json: [String: Any], + environment: [String: String]) throws -> VertexAIOAuthCredentials + { // Check for service account credentials - if json["client_email"] is String, - json["private_key"] is String - { - // Service account - use JWT for access token (simplified) + if self.parseServiceAccountMetadata(json: json) != nil { throw VertexAIOAuthCredentialsError.decodeFailed( - "Service account credentials not yet supported. Use `gcloud auth application-default login`.") + "Service account credentials require `gcloud auth application-default print-access-token`.") } // User credentials from gcloud auth application-default login @@ -127,7 +207,7 @@ public enum VertexAIOAuthCredentialsStore { let accessToken = json["access_token"] as? String ?? "" // Try to get project ID from gcloud config - let projectId = Self.loadProjectId() + let projectId = Self.loadProjectId(environment: environment) // Try to extract email from ID token if present let email = Self.extractEmailFromIdToken(json["id_token"] as? String) @@ -154,12 +234,56 @@ public enum VertexAIOAuthCredentialsStore { // The refresh happens on each app launch if needed } - private static func loadProjectId() -> String? { - let configPath = self.projectFilePath - guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + private static func parseServiceAccountMetadata(json: [String: Any]) -> ServiceAccountMetadata? { + guard let email = (json["client_email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty, + let privateKey = (json["private_key"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !privateKey.isEmpty + else { return nil } + let projectId = (json["project_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return ServiceAccountMetadata( + email: email, + projectId: projectId?.isEmpty == false ? projectId : nil) + } + + private static func printAccessToken(environment: [String: String]) async throws -> String { + #if DEBUG + if let override = self.gcloudAccessTokenOverrideForTesting { + let token = try await override(environment) + return try self.cleanAccessToken(token) + } + #endif + + let env = TTYCommandRunner.enrichedEnvironment(baseEnv: environment) + let result = try await SubprocessRunner.run( + binary: "/usr/bin/env", + arguments: ["gcloud", "auth", "application-default", "print-access-token"], + environment: env, + timeout: 20, + label: "vertexai-gcloud-adc-token") + return try self.cleanAccessToken(result.stdout) + } + + private static func cleanAccessToken(_ token: String) throws -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw VertexAIOAuthCredentialsError.missingTokens + } + return trimmed + } + + private static func loadProjectId(environment: [String: String]) -> String? { + let configPath = self.projectFilePath(environment: environment) + guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] + } + // Parse INI-style config for project for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -172,9 +296,9 @@ public enum VertexAIOAuthCredentialsStore { } // Try environment variable - return ProcessInfo.processInfo.environment["GOOGLE_CLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["GCLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["CLOUDSDK_CORE_PROJECT"] + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] } private static func extractEmailFromIdToken(_ token: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift index b5e8e3f68..206fc3060 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift @@ -49,12 +49,10 @@ public enum VertexAITokenRefresher { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw RefreshError.invalidResponse("No HTTP response") - } + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data - if http.statusCode == 400 || http.statusCode == 401 { + if response.statusCode == 400 || response.statusCode == 401 { if let errorCode = Self.extractErrorCode(from: data) { switch errorCode.lowercased() { case "invalid_grant": @@ -68,8 +66,8 @@ public enum VertexAITokenRefresher { throw RefreshError.expired } - guard http.statusCode == 200 else { - throw RefreshError.invalidResponse("Status \(http.statusCode)") + guard response.statusCode == 200 else { + throw RefreshError.invalidResponse("Status \(response.statusCode)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift index 2c9da2033..e3ad58b3a 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift @@ -215,20 +215,15 @@ public enum VertexAIUsageFetcher { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 - let data: Data - let response: URLResponse + let response: ProviderHTTPResponse do { - (data, response) = try await URLSession.shared.data(for: request) + response = try await ProviderHTTPClient.shared.response(for: request) } catch { throw VertexAIFetchError.networkError(error) } - guard let http = response as? HTTPURLResponse else { - throw VertexAIFetchError.invalidResponse("No HTTP response") - } - - switch http.statusCode { + switch response.statusCode { case 401: throw VertexAIFetchError.unauthorized case 403: @@ -236,11 +231,11 @@ public enum VertexAIUsageFetcher { case 200: break default: - let body = String(data: data, encoding: .utf8) ?? "" - throw VertexAIFetchError.invalidResponse("HTTP \(http.statusCode): \(body)") + let body = String(data: response.data, encoding: .utf8) ?? "" + throw VertexAIFetchError.invalidResponse("HTTP \(response.statusCode): \(body)") } - let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: data) + let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: response.data) if let series = decoded.timeSeries { allSeries.append(contentsOf: series) } diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index f81e0d1f2..715aac444 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -45,12 +45,12 @@ struct VertexAIOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "vertexai.oauth" let kind: ProviderFetchKind = .oauth - func isAvailable(_: ProviderFetchContext) async -> Bool { - (try? VertexAIOAuthCredentialsStore.load()) != nil + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + VertexAIOAuthCredentialsStore.hasCredentials(environment: context.env) } - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - var credentials = try VertexAIOAuthCredentialsStore.load() + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + var credentials = try await VertexAIOAuthCredentialsStore.loadForFetch(environment: context.env) // Refresh token if expired if credentials.needsRefresh { diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift index c5e2bf8a4..0114d81c4 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -206,16 +206,12 @@ public struct WarpUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw WarpUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data) - Self.log.error("Warp API returned \(httpResponse.statusCode): \(summary)") - throw WarpUsageError.apiError(httpResponse.statusCode, summary) + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: data) + Self.log.error("Warp API returned \(response.statusCode): \(summary)") + throw WarpUsageError.apiError(response.statusCode, summary) } do { diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift new file mode 100644 index 000000000..47a3504ee --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift @@ -0,0 +1,254 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum WindsurfDevinSessionImporter { + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importPreferredSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importFallbackSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let fallbackBrowsers: [Browser] = [ + .chromeBeta, + .chromeCanary, + .edge, + .edgeBeta, + .edgeCanary, + .brave, + .braveBeta, + .braveNightly, + .vivaldi, + .arc, + .arcBeta, + .arcCanary, + .dia, + .chatgptAtlas, + .chromium, + .helium, + ] + + struct SessionInfo: Equatable { + let session: WindsurfDevinSessionAuth + let sourceLabel: String + } + + static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return override(browserDetection, logger) + } + + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + let preferredSessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + if !preferredSessions.isEmpty { + return preferredSessions + } + + log("No Windsurf devin session found in Chrome; trying fallback Chromium browsers") + let sessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + + if sessions.isEmpty { + log("No Windsurf devin session found in browser local storage") + } + + return sessions + } + + static func importPreferredSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importPreferredSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + } + + static func importFallbackSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importFallbackSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + } + + static func fallbackBrowsersExcluding(_ preferredBrowsers: [Browser]) -> [Browser] { + let preferred = Set(preferredBrowsers) + return self.fallbackBrowsers.filter { !preferred.contains($0) } + } + + static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + var deduplicated: [SessionInfo] = [] + var seenSessionTokens = Set() + + for session in sessions { + guard seenSessionTokens.insert(session.session.sessionToken).inserted else { continue } + deduplicated.append(session) + } + + return deduplicated + } + + static func session(from storage: [String: String], sourceLabel: String) -> SessionInfo? { + guard let sessionToken = storage["devin_session_token"], + let auth1Token = storage["devin_auth1_token"], + let accountID = storage["devin_account_id"], + let primaryOrgID = storage["devin_primary_org_id"] + else { + return nil + } + + return SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID), + sourceLabel: sourceLabel) + } + + static func decodedStorageValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + if let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(String.self, from: data) + { + return decoded.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + struct LocalStorageCandidate { + let label: String + let url: URL + } + + private static func importSessions( + browserDetection: BrowserDetection, + browsers: [Browser], + logger: @escaping (String) -> Void) -> [SessionInfo] + { + var sessions: [SessionInfo] = [] + let candidates = self.chromeLocalStorageCandidates( + browserDetection: browserDetection, + browsers: browsers) + if !candidates.isEmpty { + logger("Chrome local storage candidates: \(candidates.count)") + } + + for candidate in candidates { + let storage = self.readLocalStorage(from: candidate.url, logger: logger) + guard let session = self.session(from: storage, sourceLabel: candidate.label) else { continue } + logger("Found Windsurf devin session in \(candidate.label)") + sessions.append(session) + } + + return self.deduplicateSessions(sessions) + } + + static func chromeLocalStorageCandidates( + browserDetection: BrowserDetection, + browsers: [Browser]) -> [LocalStorageCandidate] + { + let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection) + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [LocalStorageCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileLocalStorageDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileDirs = entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + + return profileDirs.compactMap { dir in + let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil } + let label = "\(labelPrefix) \(dir.lastPathComponent)" + return LocalStorageCandidate(label: label, url: levelDBURL) + } + } + + private static func readLocalStorage( + from levelDBURL: URL, + logger: ((String) -> Void)? = nil) -> [String: String] + { + var storage: [String: String] = [:] + + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: "https://windsurf.com", + in: levelDBURL, + logger: logger) + + for entry in entries where Self.targetKeys.contains(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + if storage.count == Self.targetKeys.count { + return storage + } + + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + + for entry in textEntries { + guard storage[entry.key] == nil, Self.targetKeys.contains(entry.key) else { continue } + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + return storage + } + + private static let targetKeys: Set = [ + "devin_session_token", + "devin_auth1_token", + "devin_account_id", + "devin_primary_org_id", + ] +} +#endif diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 000000000..377fbc398 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,101 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WindsurfProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .windsurf, + metadata: ProviderMetadata( + id: .windsurf, + displayName: "Windsurf", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://windsurf.com/subscription/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [WindsurfWebFetchStrategy(), WindsurfLocalFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "windsurf", + versionDetector: nil)) + } +} + +struct WindsurfWebFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode.usesWeb else { return false } + guard context.settings?.windsurf?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + #if os(macOS) + let cookieSource = context.settings?.windsurf?.cookieSource ?? .auto + let manualToken = Self.manualToken(from: context) + let usage = try await WindsurfWebFetcher.fetchUsage( + browserDetection: context.browserDetection, + cookieSource: cookieSource, + manualSessionInput: manualToken, + timeout: context.webTimeout, + logger: context.verbose ? { print($0) } : nil) + return self.makeResult(usage: usage, sourceLabel: "windsurf-web") + #else + throw WindsurfStatusProbeError.notSupported + #endif + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func manualToken(from context: ProviderFetchContext) -> String? { + guard context.settings?.windsurf?.cookieSource == .manual else { return nil } + let header = context.settings?.windsurf?.manualCookieHeader ?? "" + return header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : header + } +} + +struct WindsurfLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.sourceMode != .web + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let probe = WindsurfStatusProbe() + let planInfo = try probe.fetch() + let usage = planInfo.toUsageSnapshot() + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift new file mode 100644 index 000000000..73f863ec8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -0,0 +1,281 @@ +import Foundation + +// MARK: - Cached Plan Info (Codable) + +public struct WindsurfCachedPlanInfo: Codable, Sendable { + public let planName: String? + public let startTimestamp: Int64? + public let endTimestamp: Int64? + public let usage: Usage? + public let quotaUsage: QuotaUsage? + + public struct Usage: Codable, Sendable { + public let messages: Int? + public let usedMessages: Int? + public let remainingMessages: Int? + public let flowActions: Int? + public let usedFlowActions: Int? + public let remainingFlowActions: Int? + public let flexCredits: Int? + public let usedFlexCredits: Int? + public let remainingFlexCredits: Int? + } + + public struct QuotaUsage: Codable, Sendable { + public let dailyRemainingPercent: Double? + public let weeklyRemainingPercent: Double? + public let dailyResetAtUnix: Int64? + public let weeklyResetAtUnix: Int64? + } +} + +// MARK: - Errors & Probe + +#if os(macOS) + +import SQLite3 + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case dbNotFound(String) + case sqliteFailed(String) + case noData + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case let .dbNotFound(path): + "Windsurf database not found at \(path). Ensure Windsurf is installed and has been launched at least once." + case let .sqliteFailed(message): + "SQLite error reading Windsurf data: \(message)" + case .noData: + "No plan data found in Windsurf database. Sign in to Windsurf first." + case let .parseFailed(message): + "Could not parse Windsurf plan data: \(message)" + } + } +} + +// MARK: - Probe + +public struct WindsurfStatusProbe: Sendable { + private static let defaultDBPath: String = { + let home = NSHomeDirectory() + return "\(home)/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + }() + + private static let query = "SELECT value FROM ItemTable WHERE key = 'windsurf.settings.cachedPlanInfo' LIMIT 1;" + + private let dbPath: String + + public init(dbPath: String? = nil) { + self.dbPath = dbPath ?? Self.defaultDBPath + } + + public func fetch() throws -> WindsurfCachedPlanInfo { + guard FileManager.default.fileExists(atPath: self.dbPath) else { + throw WindsurfStatusProbeError.dbNotFound(self.dbPath) + } + + var db: OpaquePointer? + guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, Self.query, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + if stepResult == SQLITE_DONE { + throw WindsurfStatusProbeError.noData + } + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + + guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else { + throw WindsurfStatusProbeError.noData + } + guard let jsonData = jsonString.data(using: .utf8) else { + throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding") + } + + do { + return try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + } catch { + throw WindsurfStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + switch sqlite3_column_type(stmt, index) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + // VSCode/Windsurf state.vscdb schema declares value as BLOB; + // only accept decodes that still parse as JSON to avoid UTF-16 mojibake. + return self.decodeJSONBlob(data) + default: + return nil + } + } + + private static func decodeJSONBlob(_ data: Data) -> String? { + for encoding in [String.Encoding.utf8, .utf16LittleEndian] { + guard let decoded = String(data: data, encoding: encoding) else { continue } + let trimmed = decoded.trimmingCharacters(in: .controlCharacters) + guard let jsonData = trimmed.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: jsonData)) != nil + else { + continue + } + return trimmed + } + return nil + } +} + +#else + +// MARK: - Windsurf (Unsupported) + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "Windsurf is only supported on macOS." + } +} + +public struct WindsurfStatusProbe: Sendable { + public init(dbPath _: String? = nil) {} + + public func fetch() throws -> WindsurfCachedPlanInfo { + throw WindsurfStatusProbeError.notSupported + } +} + +#endif + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfCachedPlanInfo { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let quota = self.quotaUsage { + // Primary: daily usage (usedPercent = 100 - dailyRemainingPercent) + if let daily = quota.dailyRemainingPercent { + let resetDate = quota.dailyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - daily)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + // Secondary: weekly usage + if let weekly = quota.weeklyRemainingPercent { + let resetDate = quota.weeklyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - weekly)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + if primary == nil, let usage = self.usage { + primary = Self.makeUsageWindow( + used: usage.usedMessages, + remaining: usage.remainingMessages, + total: usage.messages, + unit: "messages") + } + + if secondary == nil, let usage = self.usage { + secondary = Self.makeUsageWindow( + used: usage.usedFlowActions, + remaining: usage.remainingFlowActions, + total: usage.flowActions, + unit: "flow actions") + } + + // Identity + var orgDescription: String? + if let endTimestamp = self.endTimestamp { + let endDate = Date(timeIntervalSince1970: TimeInterval(endTimestamp) / 1000) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func makeUsageWindow( + used rawUsed: Int?, + remaining rawRemaining: Int?, + total rawTotal: Int?, + unit: String) -> RateWindow? + { + guard let total = rawTotal, total > 0 else { return nil } + let inferredUsed = rawUsed ?? rawRemaining.map { max(0, total - $0) } + guard let used = inferredUsed else { return nil } + let clampedUsed = max(0, min(total, used)) + let usedPercent = max(0, min(100, Double(clampedUsed) / Double(total) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(clampedUsed) / \(total) \(unit)") + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift new file mode 100644 index 000000000..1330be598 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum WindsurfUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case web + case cli + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .web: "Web API (IndexedDB)" + case .cli: "Local (SQLite cache)" + } + } + + public var sourceLabel: String { + switch self { + case .auto: "auto" + case .web: "web" + case .cli: "cli" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift new file mode 100644 index 000000000..e936389bd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -0,0 +1,678 @@ +import Foundation + +// MARK: - API Response Model + +public struct WindsurfGetPlanStatusResponse: Sendable, Equatable { + public let planStatus: PlanStatus? + + public struct PlanStatus: Sendable, Equatable { + public let planInfo: PlanInfo? + public let planStart: Date? + public let planEnd: Date? + public let dailyQuotaRemainingPercent: Int? + public let weeklyQuotaRemainingPercent: Int? + public let dailyQuotaResetAtUnix: Int64? + public let weeklyQuotaResetAtUnix: Int64? + public let topUpStatus: TopUpStatus? + public let gracePeriodStatus: Int? + + public struct PlanInfo: Sendable, Equatable { + public let planName: String? + public let teamsTier: Int? + } + + public struct TopUpStatus: Sendable, Equatable { + public let topUpTransactionStatus: Int? + } + } +} + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfGetPlanStatusResponse { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let status = self.planStatus { + if let daily = status.dailyQuotaRemainingPercent { + let resetDate = status.dailyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(daily))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + if let weekly = status.weeklyQuotaRemainingPercent { + let resetDate = status.weeklyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(weekly))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + var orgDescription: String? + if let endDate = self.planStatus?.planEnd { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planStatus?.planInfo?.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} + +// MARK: - Session Material + +#if os(macOS) + +struct WindsurfDevinSessionAuth: Codable, Equatable { + let sessionToken: String + let auth1Token: String + let accountID: String + let primaryOrgID: String +} + +public enum WindsurfWebFetcherError: LocalizedError, Sendable { + case noSessionData + case invalidManualSession(String) + case apiCallFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionData: + "No Windsurf web session found in Chromium localStorage. Sign in to windsurf.com in Chrome or Edge first." + case let .invalidManualSession(message): + "Invalid Windsurf session payload: \(message)" + case let .apiCallFailed(message): + "Windsurf API call failed: \(message)" + } + } +} + +public enum WindsurfWebFetcher { + private static let windsurfOrigin = "https://windsurf.com" + private static let windsurfProfileReferer = "https://windsurf.com/profile" + private static let getPlanStatusURL = "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus" + + public static func fetchUsage( + browserDetection: BrowserDetection, + cookieSource: ProviderCookieSource = .auto, + manualSessionInput: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[windsurf-web] \(msg)") } + + if cookieSource == .manual { + guard let manualSessionInput, + !manualSessionInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + log("Using manual Windsurf session bundle") + let auth = try self.parseManualSessionInput(manualSessionInput) + let response = try await self.fetchPlanStatus(auth: auth, timeout: timeout, transport: transport) + return response.toUsageSnapshot() + } + + guard cookieSource != .off else { + throw WindsurfWebFetcherError.noSessionData + } + + let preferredSessionInfos = WindsurfDevinSessionImporter.importPreferredSessions( + browserDetection: browserDetection, + logger: logger) + let sessionInfos = preferredSessionInfos.isEmpty + ? WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + : preferredSessionInfos + guard !sessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + + do { + return try await self.fetchUsage( + sessionInfos: sessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } catch { + guard !preferredSessionInfos.isEmpty, self.isRecoverableImportedSessionError(error) else { + throw error + } + } + + log("Chrome Windsurf sessions failed; trying fallback Chromium browser sessions") + let fallbackSessionInfos = WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + guard !fallbackSessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + return try await self.fetchUsage( + sessionInfos: fallbackSessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } + + static func parseManualSessionInput(_ raw: String) throws -> WindsurfDevinSessionAuth { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + + if let auth = self.parseJSONSessionInput(trimmed) { + return auth + } + + if let auth = self.parseKeyValueSessionInput(trimmed) { + return auth + } + + throw WindsurfWebFetcherError.invalidManualSession( + "expected JSON with devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id") + } + + private static func parseJSONSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + return self.sessionAuth(from: json) + } + + private static func parseKeyValueSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + let separators = CharacterSet(charactersIn: "\n,;") + let segments = raw + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var values: [String: String] = [:] + for segment in segments { + let delimiter: Character? = segment.contains("=") ? "=" : (segment.contains(":") ? ":" : nil) + guard let delimiter, let index = segment.firstIndex(of: delimiter) else { continue } + let key = String(segment[.. Bool { + guard case let WindsurfWebFetcherError.apiCallFailed(message) = error else { + return false + } + + return ["HTTP 400", "HTTP 401", "HTTP 403"].contains { message.hasPrefix($0) } + } + + private static func fetchUsage( + sessionInfos: [WindsurfDevinSessionImporter.SessionInfo], + timeout: TimeInterval, + logger log: (String) -> Void, + transport: any ProviderHTTPTransport) async throws -> UsageSnapshot + { + var lastError: Error? + for sessionInfo in sessionInfos { + do { + log("Using devin session from \(sessionInfo.sourceLabel)") + let response = try await self.fetchPlanStatus( + auth: sessionInfo.session, + timeout: timeout, + transport: transport) + return response.toUsageSnapshot() + } catch { + guard self.isRecoverableImportedSessionError(error) else { + throw error + } + lastError = error + log("Windsurf devin session from \(sessionInfo.sourceLabel) failed; trying next imported session") + } + } + + throw lastError ?? WindsurfWebFetcherError.noSessionData + } + + private static func sessionAuth(from values: [String: Any]) -> WindsurfDevinSessionAuth? { + func stringValue(for keys: [String]) -> String? { + for key in keys { + if let value = values[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + guard let sessionToken = stringValue(for: ["devin_session_token", "devinSessionToken", "sessionToken"]), + let auth1Token = stringValue(for: ["devin_auth1_token", "devinAuth1Token", "auth1Token"]), + let accountID = stringValue(for: ["devin_account_id", "devinAccountId", "accountID", "accountId"]), + let primaryOrgID = stringValue(for: [ + "devin_primary_org_id", + "devinPrimaryOrgId", + "primaryOrgID", + "primaryOrgId", + ]) + else { + return nil + } + + return WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID) + } + + private static func fetchPlanStatus( + auth: WindsurfDevinSessionAuth, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> WindsurfGetPlanStatusResponse + { + guard let url = URL(string: self.getPlanStatusURL) else { + throw WindsurfWebFetcherError.apiCallFailed("Invalid GetPlanStatus URL") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.httpMethod = "POST" + request.setValue("application/proto", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + self.applyWindsurfHeaders(to: &request, auth: auth) + request.httpBody = WindsurfPlanStatusProtoCodec.encodeRequest( + authToken: auth.sessionToken, + includeTopUpStatus: true) + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw WindsurfWebFetcherError.apiCallFailed("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let snippet = if let body, !body.isEmpty { + ": \(body.prefix(200))" + } else { + ": " + } + throw WindsurfWebFetcherError.apiCallFailed("HTTP \(response.statusCode)\(snippet)") + } + + do { + return try WindsurfPlanStatusProtoCodec.decodeResponse(response.data) + } catch { + throw WindsurfWebFetcherError.apiCallFailed("Parse error: \(error.localizedDescription)") + } + } + + private static func applyWindsurfHeaders(to request: inout URLRequest, auth: WindsurfDevinSessionAuth) { + request.setValue(self.windsurfOrigin, forHTTPHeaderField: "Origin") + request.setValue(self.windsurfProfileReferer, forHTTPHeaderField: "Referer") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-auth-token") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-devin-session-token") + request.setValue(auth.auth1Token, forHTTPHeaderField: "x-devin-auth1-token") + request.setValue(auth.accountID, forHTTPHeaderField: "x-devin-account-id") + request.setValue(auth.primaryOrgID, forHTTPHeaderField: "x-devin-primary-org-id") + } +} + +enum WindsurfPlanStatusProtoCodec { + /// Field numbers come from Windsurf's bundled protobuf metadata in + /// `/Applications/Windsurf.app/.../extension.js` and were re-verified against live browser traffic on 2026-04-17. + struct Request: Equatable { + let authToken: String + let includeTopUpStatus: Bool + } + + static func encodeRequest(authToken: String, includeTopUpStatus: Bool) -> Data { + var data = Data() + self.appendFieldKey(1, wireType: .lengthDelimited, to: &data) + self.appendString(authToken, to: &data) + self.appendFieldKey(2, wireType: .varint, to: &data) + self.appendVarint(includeTopUpStatus ? 1 : 0, to: &data) + return data + } + + static func decodeRequest(_ data: Data) throws -> Request { + var reader = ProtoReader(data: data) + var authToken: String? + var includeTopUpStatus = false + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + authToken = try reader.readString() + case (2, .varint): + includeTopUpStatus = try reader.readVarint() != 0 + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + guard let authToken else { + throw WindsurfProtoError.missingField("auth_token") + } + + return Request(authToken: authToken, includeTopUpStatus: includeTopUpStatus) + } + + static func decodeResponse(_ data: Data) throws -> WindsurfGetPlanStatusResponse { + var reader = ProtoReader(data: data) + var planStatus: WindsurfGetPlanStatusResponse.PlanStatus? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planStatus = try self.decodePlanStatus(from: reader.readLengthDelimitedData()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse(planStatus: planStatus) + } + + private static func decodePlanStatus(from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus { + var reader = ProtoReader(data: data) + var planInfo: WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo? + var planStart: Date? + var planEnd: Date? + var dailyQuotaRemainingPercent: Int? + var weeklyQuotaRemainingPercent: Int? + var dailyQuotaResetAtUnix: Int64? + var weeklyQuotaResetAtUnix: Int64? + var topUpStatus: WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus? + var gracePeriodStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planInfo = try self.decodePlanInfo(from: reader.readLengthDelimitedData()) + case (2, .lengthDelimited): + planStart = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (3, .lengthDelimited): + planEnd = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (10, .lengthDelimited): + topUpStatus = try self.decodeTopUpStatus(from: reader.readLengthDelimitedData()) + case (12, .varint): + gracePeriodStatus = try Int(reader.readVarint()) + case (14, .varint): + dailyQuotaRemainingPercent = try Int(reader.readVarint()) + case (15, .varint): + weeklyQuotaRemainingPercent = try Int(reader.readVarint()) + case (17, .varint): + dailyQuotaResetAtUnix = try Int64(reader.readVarint()) + case (18, .varint): + weeklyQuotaResetAtUnix = try Int64(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus( + planInfo: planInfo, + planStart: planStart, + planEnd: planEnd, + dailyQuotaRemainingPercent: dailyQuotaRemainingPercent, + weeklyQuotaRemainingPercent: weeklyQuotaRemainingPercent, + dailyQuotaResetAtUnix: dailyQuotaResetAtUnix, + weeklyQuotaResetAtUnix: weeklyQuotaResetAtUnix, + topUpStatus: topUpStatus, + gracePeriodStatus: gracePeriodStatus) + } + + private static func decodePlanInfo( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo + { + var reader = ProtoReader(data: data) + var planName: String? + var teamsTier: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + teamsTier = try Int(reader.readVarint()) + case (2, .lengthDelimited): + planName = try reader.readString() + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo(planName: planName, teamsTier: teamsTier) + } + + private static func decodeTopUpStatus( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus + { + var reader = ProtoReader(data: data) + var topUpTransactionStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + topUpTransactionStatus = try Int(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus( + topUpTransactionStatus: topUpTransactionStatus) + } + + private static func decodeTimestamp(from data: Data) throws -> Date { + var reader = ProtoReader(data: data) + var seconds: Int64 = 0 + var nanos: Int32 = 0 + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + seconds = try Int64(reader.readVarint()) + case (2, .varint): + nanos = try Int32(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + let timeInterval = TimeInterval(seconds) + (TimeInterval(nanos) / 1_000_000_000) + return Date(timeIntervalSince1970: timeInterval) + } + + private static func appendString(_ string: String, to data: inout Data) { + let encoded = Data(string.utf8) + self.appendVarint(UInt64(encoded.count), to: &data) + data.append(encoded) + } + + private static func appendFieldKey(_ fieldNumber: Int, wireType: ProtoWireType, to data: inout Data) { + self.appendVarint(UInt64((fieldNumber << 3) | Int(wireType.rawValue)), to: &data) + } + + private static func appendVarint(_ value: UInt64, to data: inout Data) { + var remaining = value + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + } +} + +enum WindsurfProtoError: LocalizedError { + case truncated + case invalidWireType(UInt64) + case invalidUTF8 + case missingField(String) + case unsupportedWireType(ProtoWireType) + case malformedFieldKey + + var errorDescription: String? { + switch self { + case .truncated: + "truncated protobuf payload" + case let .invalidWireType(rawValue): + "invalid wire type \(rawValue)" + case .invalidUTF8: + "invalid UTF-8 string" + case let .missingField(name): + "missing protobuf field \(name)" + case let .unsupportedWireType(type): + "unsupported protobuf wire type \(type.rawValue)" + case .malformedFieldKey: + "malformed protobuf field key" + } + } +} + +enum ProtoWireType: UInt64 { + case varint = 0 + case fixed64 = 1 + case lengthDelimited = 2 + case startGroup = 3 + case endGroup = 4 + case fixed32 = 5 +} + +private struct ProtoField { + let number: Int + let wireType: ProtoWireType +} + +private struct ProtoReader { + private let bytes: [UInt8] + private var index: Int = 0 + + init(data: Data) { + self.bytes = Array(data) + } + + mutating func nextField() throws -> ProtoField? { + guard self.index < self.bytes.count else { return nil } + let key = try self.readVarint() + let number = Int(key >> 3) + guard number > 0 else { + throw WindsurfProtoError.malformedFieldKey + } + guard let wireType = ProtoWireType(rawValue: key & 0x07) else { + throw WindsurfProtoError.invalidWireType(key & 0x07) + } + return ProtoField(number: number, wireType: wireType) + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while self.index < self.bytes.count { + let byte = self.bytes[self.index] + self.index += 1 + + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { + return result + } + + shift += 7 + if shift >= 64 { + throw WindsurfProtoError.truncated + } + } + + throw WindsurfProtoError.truncated + } + + mutating func readLengthDelimitedData() throws -> Data { + let length = try Int(self.readVarint()) + guard length >= 0, self.index + length <= self.bytes.count else { + throw WindsurfProtoError.truncated + } + + let chunk = Data(self.bytes[self.index..<(self.index + length)]) + self.index += length + return chunk + } + + mutating func readString() throws -> String { + let data = try self.readLengthDelimitedData() + guard let string = String(data: data, encoding: .utf8) else { + throw WindsurfProtoError.invalidUTF8 + } + return string + } + + mutating func skipFieldBody(wireType: ProtoWireType) throws { + switch wireType { + case .varint: + _ = try self.readVarint() + case .fixed64: + guard self.index + 8 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 8 + case .lengthDelimited: + _ = try self.readLengthDelimitedData() + case .fixed32: + guard self.index + 4 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 4 + case .startGroup, .endGroup: + throw WindsurfProtoError.unsupportedWireType(wireType) + } + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 18d5a15e9..7a7943286 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -5,6 +5,7 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { case bigmodelCN = "bigmodel-cn" private static let quotaPath = "api/monitor/usage/quota/limit" + private static let modelUsagePath = "api/monitor/usage/model-usage" public var displayName: String { switch self { @@ -27,4 +28,8 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var quotaLimitURL: URL { URL(string: self.baseURLString)!.appendingPathComponent(Self.quotaPath) } + + public var modelUsageURL: URL { + URL(string: self.baseURLString)!.appendingPathComponent(Self.modelUsagePath) + } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index d644451c6..2c600a3d4 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -53,7 +53,7 @@ struct ZaiAPIFetchStrategy: ProviderFetchStrategy { throw ZaiSettingsError.missingToken } let region = context.settings?.zai?.apiRegion ?? .global - let usage = try await ZaiUsageFetcher.fetchUsage( + let usage = try await ZaiUsageFetcher.fetchUsageWithModelUsage( apiKey: apiKey, region: region, environment: context.env) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 1936c9f7e..eb45fc579 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -96,6 +96,10 @@ extension ZaiLimitEntry { return "\(description) window" } + var isMCPMonthlyMarker: Bool { + self.type == .timeLimit && self.unit == .minutes && self.number == 1 + } + private var computedUsedPercent: Double? { guard let limit = self.usage, limit > 0 else { return nil } @@ -137,6 +141,7 @@ public struct ZaiUsageSnapshot: Sendable { public let sessionTokenLimit: ZaiLimitEntry? public let timeLimit: ZaiLimitEntry? public let planName: String? + public let modelUsage: ZaiModelUsageData? public let updatedAt: Date public init( @@ -144,12 +149,14 @@ public struct ZaiUsageSnapshot: Sendable { sessionTokenLimit: ZaiLimitEntry? = nil, timeLimit: ZaiLimitEntry?, planName: String?, + modelUsage: ZaiModelUsageData? = nil, updatedAt: Date) { self.tokenLimit = tokenLimit self.sessionTokenLimit = sessionTokenLimit self.timeLimit = timeLimit self.planName = planName + self.modelUsage = modelUsage self.updatedAt = updatedAt } @@ -197,6 +204,9 @@ extension ZaiUsageSnapshot { } private static func resetDescription(for limit: ZaiLimitEntry) -> String? { + if limit.isMCPMonthlyMarker { + return "Monthly" + } if let label = limit.windowLabel { return label } @@ -316,16 +326,12 @@ public struct ZaiUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw ZaiUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("z.ai API returned \(httpResponse.statusCode): \(errorMessage)") - throw ZaiUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + Self.log.error("z.ai API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } // Some upstream issues (wrong endpoint/region/proxy) can yield HTTP 200 with an empty body. @@ -411,6 +417,7 @@ public struct ZaiUsageFetcher: Sendable { sessionTokenLimit: sessionTokenLimit, timeLimit: timeLimit, planName: responseData.planName, + modelUsage: nil, updatedAt: Date()) } @@ -431,6 +438,279 @@ public struct ZaiUsageFetcher: Sendable { } } +// MARK: - Model Usage Data + +/// Per-model hourly token usage from the z.ai model-usage API +public struct ZaiModelUsageData: Sendable { + public let xTime: [String] + public let modelDataList: [ZaiModelDataItem] + + public init(xTime: [String], modelDataList: [ZaiModelDataItem]) { + self.xTime = xTime + self.modelDataList = modelDataList + } + + public var modelNames: [String] { + self.modelDataList.compactMap(\.modelName) + } +} + +public struct ZaiModelDataItem: Sendable { + public let modelName: String? + public let tokensUsage: [Int?] + + public init(modelName: String?, tokensUsage: [Int?]) { + self.modelName = modelName + self.tokensUsage = tokensUsage + } +} + +// MARK: - Hourly Chart Data + +public enum ZaiHourlyRange: Equatable, Sendable { + case today(referenceDate: Date) + case last24h + + public var isToday: Bool { + if case .today = self { return true } + return false + } +} + +public struct ZaiHourlyBar: Sendable { + public let label: String + public let segments: [(model: String, tokens: Int)] + + public init(label: String, segments: [(model: String, tokens: Int)]) { + self.label = label + self.segments = segments + } + + public var totalTokens: Int { + self.segments.reduce(0) { $0 + $1.tokens } + } +} + +public enum ZaiHourlyBars: Sendable { + public static func from(modelData: ZaiModelUsageData, range: ZaiHourlyRange, now: Date = Date()) -> [ZaiHourlyBar] { + let calendar = Calendar.current + let referenceDate: Date = switch range { + case let .today(ref): ref + case .last24h: now + } + + let todayStart = calendar.startOfDay(for: referenceDate) + let cutoff: Date = switch range { + case .today: todayStart + case .last24h: calendar.date(byAdding: .hour, value: -24, to: now) ?? now + } + + var bars: [ZaiHourlyBar] = [] + for (index, timeString) in modelData.xTime.enumerated() { + guard let hourDate = parseHourDate(timeString) else { continue } + + if hourDate < cutoff { continue } + + var segments: [(model: String, tokens: Int)] = [] + for item in modelData.modelDataList { + guard index < item.tokensUsage.count, + let tokenCount = item.tokensUsage[index], tokenCount > 0 + else { continue } + segments.append((model: item.modelName ?? "Unknown", tokens: tokenCount)) + } + + let total = segments.reduce(0) { $0 + $1.tokens } + guard total > 0 else { continue } + + let label = self.formatHourLabel(hourDate: hourDate) + bars.append(ZaiHourlyBar(label: label, segments: segments)) + } + + return bars + } + + public static func parseHourDate(_ string: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.date(from: string) + } + + private static func formatHourLabel(hourDate: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: hourDate) + } +} + +// MARK: - Model Usage Fetcher Extension + +extension ZaiUsageFetcher { + /// Fetches hourly model usage data for the last 24 hours + public static func fetchModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiModelUsageData + { + guard !apiKey.isEmpty else { + throw ZaiUsageError.invalidCredentials + } + + let baseURL: URL = if let host = ZaiSettingsReader.apiHost(environment: environment), + let resolved = Self.modelUsageURL(baseURLString: host) + { + resolved + } else { + region.modelUsageURL + } + + let now = Date() + let calendar = Calendar.current + guard let startDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) else { + throw ZaiUsageError.parseFailed("Invalid date calculation") + } + + let startComponents = calendar.dateComponents([.year, .month, .day, .hour], from: startDate) + let endComponents = calendar.dateComponents([.year, .month, .day, .hour], from: now) + let startTime = String( + format: "%04d-%02d-%02d %02d:00:00", + startComponents.year!, + startComponents.month!, + startComponents.day!, + startComponents.hour!) + let endTime = String( + format: "%04d-%02d-%02d %02d:59:59", + endComponents.year!, + endComponents.month!, + endComponents.day!, + endComponents.hour!) + + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw ZaiUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "startTime", value: startTime), + URLQueryItem(name: "endTime", value: endTime), + ] + + guard let requestURL = components.url else { + throw ZaiUsageError.networkError("Invalid URL") + } + + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Self.log.error("z.ai model-usage API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") + } + + guard !data.isEmpty else { return ZaiModelUsageData(xTime: [], modelDataList: []) } + + return try Self.parseModelUsage(from: data) + } + + static func parseModelUsage(from data: Data) throws -> ZaiModelUsageData { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(ZaiModelUsageAPIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw ZaiUsageError.apiError(apiResponse.msg) + } + + guard let responseData = apiResponse.data else { + return ZaiModelUsageData(xTime: [], modelDataList: []) + } + + let items = responseData.modelDataList?.map { raw in + ZaiModelDataItem( + modelName: raw.modelName, + tokensUsage: raw.tokensUsage ?? []) + } ?? [] + + return ZaiModelUsageData( + xTime: responseData.xTime ?? [], + modelDataList: items) + } + + /// Fetches required quota data and attaches optional model usage when available. + public static func fetchUsageWithModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiUsageSnapshot + { + let snapshot = try await Self.fetchUsage(apiKey: apiKey, region: region, environment: environment) + let modelUsage: ZaiModelUsageData? + do { + modelUsage = try await Self.fetchModelUsage(apiKey: apiKey, region: region, environment: environment) + } catch { + Self.log.info("z.ai model usage fetch failed (non-fatal): \(error.localizedDescription)") + modelUsage = nil + } + + guard modelUsage != nil else { return snapshot } + + return ZaiUsageSnapshot( + tokenLimit: snapshot.tokenLimit, + sessionTokenLimit: snapshot.sessionTokenLimit, + timeLimit: snapshot.timeLimit, + planName: snapshot.planName, + modelUsage: modelUsage, + updatedAt: snapshot.updatedAt) + } + + private static func modelUsageURL(baseURLString: String) -> URL? { + guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } + let path = "api/monitor/usage/model-usage" + + if let url = URL(string: cleaned), url.scheme != nil { + if url.path.isEmpty || url.path == "/" { + return url.appendingPathComponent(path) + } + return url + } + guard let base = URL(string: "https://\(cleaned)") else { return nil } + if base.path.isEmpty || base.path == "/" { + return base.appendingPathComponent(path) + } + return base + } +} + +// MARK: - Model Usage API Response (private) + +private struct ZaiModelUsageAPIResponse: Decodable { + let code: Int + let msg: String + let data: ZaiModelUsageRawData? + let success: Bool + + var isSuccess: Bool { + self.success && self.code == 200 + } +} + +private struct ZaiModelUsageRawData: Decodable { + let xTime: [String]? + let modelDataList: [ZaiModelDataItemRaw]? + + enum CodingKeys: String, CodingKey { + case xTime = "x_time" + case modelDataList + } +} + +private struct ZaiModelDataItemRaw: Decodable { + let modelName: String? + let tokensUsage: [Int?]? +} + /// Errors that can occur during z.ai usage fetching public enum ZaiUsageError: LocalizedError, Sendable { case invalidCredentials diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index cadd22be0..64b5eaf2d 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -42,16 +42,39 @@ public enum TokenAccountSupportCatalog { return [key: token] case .cookieHeader: if provider == .claude, - case let .oauth(accessToken) = ClaudeCredentialRouting.resolve( - tokenAccountToken: token, - manualCookieHeader: nil) + case let route = ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil) { - return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] + switch route { + case let .oauth(accessToken): + return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] + case let .adminAPIKey(apiKey): + return [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: apiKey] + case .none, .webCookie: + break + } } return nil } } + public static func scrubEnvironmentForSelectedAccount( + _ environment: inout [String: String], + provider: UsageProvider, + token _: String) + { + guard let support = self.support(for: provider) else { return } + switch support.injection { + case let .environment(key): + environment.removeValue(forKey: key) + case .cookieHeader: + guard provider == .claude else { return } + environment.removeValue(forKey: ClaudeOAuthCredentialsStore.environmentTokenKey) + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + environment.removeValue(forKey: key) + } + } + } + public static func normalizedCookieHeader(for provider: UsageProvider, token: String) -> String { guard let support = self.support(for: provider) else { return token.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 893be0d5a..68c0ccc5a 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,13 +2,34 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ + .openai: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple OpenAI API keys.", + placeholder: "sk-admin-...", + injection: .environment(key: OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .claude: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", - placeholder: "Paste sessionKey or OAuth token…", + title: "Claude credentials", + subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", + placeholder: "Paste sessionKey, OAuth token, or sk-ant-admin…", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), + .deepseek: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepSeek API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .antigravity: TokenAccountSupport( + title: "Google accounts", + subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.", + placeholder: "Antigravity OAuth credentials JSON", + injection: .environment(key: AntigravityOAuthCredentialsStore.environmentCredentialsKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", @@ -39,8 +60,8 @@ extension TokenAccountSupportCatalog { cookieName: nil), .factory: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Factory Cookie headers.", - placeholder: "Cookie: …", + subtitle: "Store multiple Factory Cookie or Authorization headers.", + placeholder: "Cookie: … or Authorization: Bearer …", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), @@ -51,6 +72,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .manus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Manus session_id cookies.", + placeholder: "session_id=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: "session_id"), .augment: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Augment Cookie headers.", @@ -79,5 +107,33 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), + .venice: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple Venice API keys.", + placeholder: "Paste API key…", + injection: .environment(key: VeniceSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .elevenlabs: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple ElevenLabs API keys.", + placeholder: "Paste API key…", + injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .stepfun: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple StepFun Oasis-Token values.", + placeholder: "Oasis-Token=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec..73110d28d 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -6,18 +6,53 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? + /// Stable provider-specific identity (e.g. GitHub `login`) used for + /// re-auth deduplication. Optional so legacy accounts keep working. + public let externalIdentifier: String? + /// Optional provider-specific organization/workspace target. Claude web + /// sessionKey accounts use this to disambiguate linked Anthropic emails. + public let organizationID: String? - public init(id: UUID, label: String, token: String, addedAt: TimeInterval, lastUsed: TimeInterval?) { + enum CodingKeys: String, CodingKey { + case id + case label + case token + case addedAt + case lastUsed + case externalIdentifier + case organizationID = "organizationId" + } + + public init( + id: UUID, + label: String, + token: String, + addedAt: TimeInterval, + lastUsed: TimeInterval?, + externalIdentifier: String? = nil, + organizationID: String? = nil) + { self.id = id self.label = label self.token = token self.addedAt = addedAt self.lastUsed = lastUsed + self.externalIdentifier = externalIdentifier + self.organizationID = organizationID } public var displayName: String { self.label } + + public var sanitizedOrganizationID: String? { + Self.clean(self.organizationID) + } + + private static func clean(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty ?? true) ? nil : trimmed + } } public struct ProviderTokenAccountData: Codable, Sendable { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 22f53e1c1..a531e5a78 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -26,6 +26,17 @@ public struct RateWindow: Codable, Equatable, Sendable { public var remainingPercent: Double { max(0, 100 - self.usedPercent) } + + public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { + if self.resetsAt != nil { return self } + guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + return RateWindow( + usedPercent: self.usedPercent, + windowMinutes: self.windowMinutes ?? cached?.windowMinutes, + resetsAt: cachedReset, + resetDescription: self.resetDescription ?? cached?.resetDescription, + nextRegenPercent: self.nextRegenPercent) + } } public struct NamedRateWindow: Codable, Equatable, Sendable { @@ -74,9 +85,14 @@ public struct UsageSnapshot: Codable, Sendable { public let tertiary: RateWindow? public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? + public let kiroUsage: KiroUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? + public let openAIAPIUsage: OpenAIAPIUsageSnapshot? + public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? + public let mistralUsage: MistralUsageSnapshot? + public let deepgramUsage: DeepgramUsageSnapshot? public let cursorRequests: CursorRequestUsage? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -87,7 +103,12 @@ public struct UsageSnapshot: Codable, Sendable { case tertiary case extraRateWindows case providerCost + case kiroUsage case openRouterUsage + case openAIAPIUsage + case claudeAdminAPIUsage + case mistralUsage + case deepgramUsage case updatedAt case identity case accountEmail @@ -100,10 +121,15 @@ public struct UsageSnapshot: Codable, Sendable { secondary: RateWindow?, tertiary: RateWindow? = nil, extraRateWindows: [NamedRateWindow]? = nil, + kiroUsage: KiroUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, + claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, + mistralUsage: MistralUsageSnapshot? = nil, + deepgramUsage: DeepgramUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) @@ -112,10 +138,15 @@ public struct UsageSnapshot: Codable, Sendable { self.secondary = secondary self.tertiary = tertiary self.extraRateWindows = extraRateWindows + self.kiroUsage = kiroUsage self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage self.openRouterUsage = openRouterUsage + self.openAIAPIUsage = openAIAPIUsage + self.claudeAdminAPIUsage = claudeAdminAPIUsage + self.mistralUsage = mistralUsage + self.deepgramUsage = deepgramUsage self.cursorRequests = cursorRequests self.updatedAt = updatedAt self.identity = identity @@ -128,9 +159,16 @@ public struct UsageSnapshot: Codable, Sendable { self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) + 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.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) + self.claudeAdminAPIUsage = try container.decodeIfPresent( + ClaudeAdminAPIUsageSnapshot.self, + forKey: .claudeAdminAPIUsage) + self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) + self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { @@ -159,7 +197,12 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.tertiary, forKey: .tertiary) try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) + try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) + try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) + try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) + try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) @@ -236,6 +279,15 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } + public var hasRateLimitWindows: Bool { + self.primary != nil || self.secondary != nil || self.tertiary != nil || + !(self.extraRateWindows?.isEmpty ?? true) + } + + public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { + UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable + } + /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { UsageSnapshot( @@ -243,10 +295,15 @@ public struct UsageSnapshot: Codable, Sendable { secondary: self.secondary, tertiary: self.tertiary, extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, updatedAt: self.updatedAt, identity: identity) @@ -259,24 +316,77 @@ public struct UsageSnapshot: Codable, Sendable { return self.withIdentity(scopedIdentity) } + public func backfillingResetTimes(from cached: UsageSnapshot?, now: Date = .init()) -> UsageSnapshot { + guard let cached else { return self } + guard Self.identitiesMatch(self.identity, cached.identity) else { return self } + let primary = self.primary?.backfillingResetTime(from: cached.primary, now: now) + let secondary = self.secondary?.backfillingResetTime(from: cached.secondary, now: now) + let tertiary = self.tertiary?.backfillingResetTime(from: cached.tertiary, now: now) + if primary == self.primary, secondary == self.secondary, tertiary == self.tertiary { + return self + } + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: self.extraRateWindows, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + cursorRequests: self.cursorRequests, + updatedAt: self.updatedAt, + identity: self.identity) + } + private func orderedPerplexityFallbackWindows() -> [RateWindow] { let fallbackWindows = [self.tertiary, self.secondary].compactMap(\.self) let usableFallback = fallbackWindows.filter { $0.remainingPercent > 0 } let exhaustedFallback = fallbackWindows.filter { $0.remainingPercent <= 0 } return usableFallback + exhaustedFallback } + + private static func identitiesMatch(_ lhs: ProviderIdentitySnapshot?, _ rhs: ProviderIdentitySnapshot?) -> Bool { + if lhs == nil, rhs == nil { return true } + guard let lhs, let rhs else { return false } + let lhsEmail = lhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let rhsEmail = rhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let lhsEmail, let rhsEmail, !lhsEmail.isEmpty, !rhsEmail.isEmpty { + return lhsEmail == rhsEmail + } + return true + } } public struct AccountInfo: Equatable, Sendable { public let email: String? public let plan: String? + public var hasIdentity: Bool { + self.email?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + self.plan?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + public init(email: String?, plan: String?) { self.email = email self.plan = plan } } +public struct CodexCLIAccountSnapshot: Sendable { + public let usage: UsageSnapshot? + public let credits: CreditsSnapshot? + + public init(usage: UsageSnapshot?, credits: CreditsSnapshot?) { + self.usage = usage + self.credits = credits + } +} + public enum UsageError: LocalizedError, Sendable { case noSessions case noRateLimitsFound @@ -292,6 +402,40 @@ public enum UsageError: LocalizedError, Sendable { "Could not parse Codex session log." } } + + public static func isNoRateLimitsFoundDescription(_ text: String?) -> Bool { + text?.trimmingCharacters(in: .whitespacesAndNewlines) == UsageError.noRateLimitsFound.errorDescription + } +} + +public enum UsageLimitsAvailability: Equatable, Sendable { + case available + case unavailable + + public var isUnavailable: Bool { + self == .unavailable + } + + public static func resolve( + provider: UsageProvider, + snapshot: UsageSnapshot?, + account: AccountInfo? = nil, + lastErrorDescription: String? = nil) -> Self + { + guard provider == .codex else { return .available } + + if let snapshot { + guard snapshot.identity(for: provider) != nil else { return .available } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + + guard UsageError.isNoRateLimitsFoundDescription(lastErrorDescription), + account?.hasIdentity == true + else { + return .available + } + return .unavailable + } } // MARK: - Codex RPC client (local process) @@ -338,6 +482,7 @@ private struct RPCRateLimitSnapshot: Decodable, Encodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? + let planType: String? } private struct RPCRateLimitWindow: Decodable, Encodable { @@ -366,10 +511,11 @@ private struct RPCRateLimitsErrorBody: Decodable { } } -private enum RPCWireError: Error, LocalizedError { +enum RPCWireError: Error, LocalizedError { case startFailed(String) case requestFailed(String) case malformed(String) + case timeout(method: String) var errorDescription: String? { switch self { @@ -379,6 +525,8 @@ private enum RPCWireError: Error, LocalizedError { "Codex connection failed: \(message)" case let .malformed(message): "Codex returned invalid data: \(message)" + case let .timeout(method): + "Codex RPC timed out waiting for `\(method)` reply." } } } @@ -393,6 +541,8 @@ private final class CodexRPCClient: @unchecked Sendable { private let stdoutLineStream: AsyncStream private let stdoutLineContinuation: AsyncStream.Continuation private var nextID = 1 + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval private final class LineBuffer: @unchecked Sendable { private let lock = NSLock() @@ -424,8 +574,12 @@ private final class CodexRPCClient: @unchecked Sendable { init( executable: String = "codex", arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], - environment: [String: String] = ProcessInfo.processInfo.environment) throws + environment: [String: String] = ProcessInfo.processInfo.environment, + initializeTimeoutSeconds: TimeInterval = 8.0, + requestTimeoutSeconds: TimeInterval = 3.0) throws { + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds var stdoutContinuation: AsyncStream.Continuation! self.stdoutLineStream = AsyncStream { continuation in stdoutContinuation = continuation @@ -452,12 +606,19 @@ private final class CodexRPCClient: @unchecked Sendable { self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe + if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolvedExec) { + Self.log.warning("Codex RPC launch skipped after recent launch failure", metadata: ["binary": resolvedExec]) + throw RPCWireError.startFailed(message) + } + do { try self.process.run() Self.log.debug("Codex RPC started", metadata: ["binary": resolvedExec]) } catch { - Self.log.warning("Codex RPC failed to start", metadata: ["error": error.localizedDescription]) - throw RPCWireError.startFailed(error.localizedDescription) + let message = error.localizedDescription + let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: resolvedExec, message: message) + Self.log.warning("Codex RPC failed to start", metadata: ["error": message]) + throw RPCWireError.startFailed(throttled ?? message) } let stdoutHandle = self.stdoutPipe.fileHandleForReading @@ -497,7 +658,8 @@ private final class CodexRPCClient: @unchecked Sendable { func initialize(clientName: String, clientVersion: String) async throws { _ = try await self.request( method: "initialize", - params: ["clientInfo": ["name": clientName, "version": clientVersion]]) + params: ["clientInfo": ["name": clientName, "version": clientVersion]], + timeout: self.initializeTimeoutSeconds) try self.sendNotification(method: "initialized") } @@ -520,26 +682,72 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - JSON-RPC helpers - private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] { + private struct SendableJSONMessage: @unchecked Sendable { + let value: [String: Any] + } + + private func request( + method: String, + params: [String: Any]? = nil, + timeout: TimeInterval? = nil) async throws -> [String: Any] + { let id = self.nextID self.nextID += 1 try self.sendRequest(id: id, method: method, params: params) - while true { - let message = try await self.readNextMessage() + let resolvedTimeout = timeout ?? self.requestTimeoutSeconds + let wrapped = try await self.withTimeout(seconds: resolvedTimeout, method: method) { + while true { + let message = try await self.readNextMessage() - if message["id"] == nil, let methodName = message["method"] as? String { - Self.debugWriteStderr("[codex notify] \(methodName)\n") - continue - } + if message["id"] == nil, let methodName = message["method"] as? String { + Self.debugWriteStderr("[codex notify] \(methodName)\n") + continue + } + + guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } - guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { + throw RPCWireError.requestFailed(messageText) + } - if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { - throw RPCWireError.requestFailed(messageText) + return SendableJSONMessage(value: message) } + } + return wrapped.value + } - return message + private func withTimeout( + seconds: TimeInterval, + method: String, + body: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await body() + } + group.addTask { [weak self] in + try await Task.sleep(for: .seconds(seconds)) + self?.terminateProcessForTimeout(method: method) + throw RPCWireError.timeout(method: method) + } + do { + guard let result = try await group.next() else { + throw RPCWireError.timeout(method: method) + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error + } + } + } + + private func terminateProcessForTimeout(method: String) { + if self.process.isRunning { + Self.log.warning("Codex RPC timed out on `\(method)`; terminating process") + self.process.terminate() } } @@ -594,37 +802,41 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - Public fetcher used by the app public struct UsageFetcher: Sendable { - typealias CodexStatusFetcher = @Sendable ([String: String], Bool) async throws -> CodexStatusSnapshot - private let environment: [String: String] - private let codexStatusFetcher: CodexStatusFetcher + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval public init(environment: [String: String] = ProcessInfo.processInfo.environment) { - self.init(environment: environment) { environment, keepCLISessionsAlive in - try await CodexStatusProbe( - keepCLISessionsAlive: keepCLISessionsAlive, - environment: environment) - .fetch() - } + self.environment = environment + self.initializeTimeoutSeconds = 8.0 + self.requestTimeoutSeconds = 3.0 + LoginShellPathCache.shared.captureOnce() } init( environment: [String: String], - codexStatusFetcher: @escaping CodexStatusFetcher) + initializeTimeoutSeconds: TimeInterval, + requestTimeoutSeconds: TimeInterval) { self.environment = environment - self.codexStatusFetcher = codexStatusFetcher + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds LoginShellPathCache.shared.captureOnce() } public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { - try await self.withFallback( - primary: self.loadRPCUsage, - secondary: { try await self.loadTTYUsage(keepCLISessionsAlive: keepCLISessionsAlive) }) + _ = keepCLISessionsAlive + guard let usage = try await self.loadLatestCLIAccountSnapshot().usage else { + throw UsageError.noRateLimitsFound + } + return usage } - private func loadRPCUsage() async throws -> UsageSnapshot { - let rpc = try CodexRPCClient(environment: self.environment) + public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { + let rpc = try CodexRPCClient( + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds) defer { rpc.shutdown() } do { try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") @@ -633,6 +845,7 @@ public struct UsageFetcher: Sendable { // for the same pipe. let limits = try await rpc.fetchRateLimits().rateLimits let account = try? await rpc.fetchAccount() + let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: account?.account.flatMap { details in @@ -641,99 +854,47 @@ public struct UsageFetcher: Sendable { accountOrganization: nil, loginMethod: account?.account.flatMap { details in if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - guard let state = CodexReconciledState.fromCLI( + } ?? rateLimitsPlan) + let credits = Self.makeCredits(from: limits.credits) + let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil + let usage = CodexReconciledState.fromCLI( primary: Self.makeWindow(from: limits.primary), secondary: Self.makeWindow(from: limits.secondary), - identity: identity) - else { + identity: identity)? + .toUsageSnapshot() + ?? (shouldReturnUnavailableUsage ? Self.emptyCodexUsageSnapshotIfIdentified(identity: identity) : nil) + guard usage != nil || credits != nil else { throw UsageError.noRateLimitsFound } - return state.toUsageSnapshot() + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) } catch { - if let snapshot = Self.recoverUsageFromRPCError(error) { - return snapshot + let usage = Self.recoverUsageFromRPCError(error) + let credits = Self.recoverCreditsFromRPCError(error) + if usage != nil || credits != nil { + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) } throw error } } - private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - do { - let status = try await self.codexStatusFetcher(self.environment, keepCLISessionsAlive) - guard let state = CodexReconciledState.fromCLI( - primary: Self.makeTTYWindow( - percentLeft: status.fiveHourPercentLeft, - windowMinutes: 300, - resetsAt: status.fiveHourResetsAt, - resetDescription: status.fiveHourResetDescription), - secondary: Self.makeTTYWindow( - percentLeft: status.weeklyPercentLeft, - windowMinutes: 10080, - resetsAt: status.weeklyResetsAt, - resetDescription: status.weeklyResetDescription), - identity: nil) - else { - throw UsageError.noRateLimitsFound - } - return state.toUsageSnapshot() - } catch { - throw error - } - } - public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { - try await self.withFallback( - primary: self.loadRPCCredits, - secondary: { try await self.loadTTYCredits(keepCLISessionsAlive: keepCLISessionsAlive) }) - } - - private func loadRPCCredits() async throws -> CreditsSnapshot { - let rpc = try CodexRPCClient(environment: self.environment) - defer { rpc.shutdown() } - do { - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) - } catch { - if let credits = Self.recoverCreditsFromRPCError(error) { - return credits - } - throw error - } - } - - private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - do { - let status = try await self.codexStatusFetcher(self.environment, keepCLISessionsAlive) - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) - } catch { - throw error - } - } - - private func withFallback( - primary: @escaping () async throws -> T, - secondary: @escaping () async throws -> T) async throws -> T - { - do { - return try await primary() - } catch let primaryError { - do { - return try await secondary() - } catch { - // Preserve the original failure so callers see the primary path error. - throw primaryError - } + _ = keepCLISessionsAlive + guard let credits = try await self.loadLatestCLIAccountSnapshot().credits else { + throw UsageError.noRateLimitsFound } + return credits } public func debugRawRateLimits() async -> String { do { - let rpc = try CodexRPCClient(environment: self.environment) + let rpc = try CodexRPCClient( + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() @@ -813,6 +974,21 @@ public struct UsageFetcher: Sendable { return val } + private static func makeCredits(from rpc: RPCCreditsSnapshot?) -> CreditsSnapshot? { + guard let rpc else { return nil } + return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) + } + + private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { + guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } let identity = ProviderIdentitySnapshot( @@ -917,13 +1093,22 @@ public struct UsageFetcher: Sendable { extension UsageFetcher { static func _mapCodexRPCLimitsForTesting( primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, - secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?) throws -> UsageSnapshot + secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + planType: String? = nil) throws -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(planType)) guard let state = CodexReconciledState.fromCLI( primary: primary.map(self.makeTestingWindow), secondary: secondary.map(self.makeTestingWindow), - identity: nil) + identity: identity) else { + if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { + return usage + } throw UsageError.noRateLimitsFound } return state.toUsageSnapshot() diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 85c011d6d..a15437773 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -76,6 +76,7 @@ public enum UsageFormatter { 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.unitsStyle = .abbreviated return "Updated \(rel.localizedString(for: date, relativeTo: now))" #else @@ -102,12 +103,32 @@ public enum UsageFormatter { return "\(formatted) left" } + public static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) + } + /// Formats a USD value with proper negative handling and thousand separators. /// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting. public static func usdString(_ value: Double) -> String { value.formatted(.currency(code: "USD").locale(Locale(identifier: "en_US"))) } + public static let costEstimateHint = "Estimated from local logs · may differ from your bill" + + public static func costEstimateHint(provider: UsageProvider) -> String { + switch provider { + case .claude: + "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + + "and may differ from Claude Code /status." + default: + self.costEstimateHint + } + } + /// Formats a currency value with the specified currency code. /// Uses FormatStyle with explicit en_US locale to ensure consistent formatting /// regardless of the user's system locale (e.g., pt-BR users see $54.72 not US$ 54,72). @@ -145,6 +166,25 @@ public enum UsageFormatter { return formatter.string(from: NSNumber(value: value)) ?? "\(value)" } + public static func byteCountString(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(Swift.abs(bytes)) + let units: [(threshold: Double, divisor: Double, suffix: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"), + (1024 * 1024, 1024 * 1024, "MB"), + (1024, 1024, "KB"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, scaled) + return "\(sign)\(formatted) \(unit.suffix)" + } + + return "\(bytes) B" + } + public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 73ccb4c59..6ed376ace 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 4 + 7 case .claude, .vertexai: 2 default: @@ -76,8 +76,10 @@ struct CostUsageFileUsage: Codable { var parsedBytes: Int64? var lastModel: String? var lastTotals: CostUsageCodexTotals? + var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3f7979604..f8dbc147f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,11 +1,47 @@ import Foundation enum CostUsagePricing { + private static let codexPriorityInputTokenLimit = 272_000 + struct CodexPricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheReadInputCostPerToken: Double? let displayLabel: String? + + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + let priorityInputCostPerToken: Double? + let priorityOutputCostPerToken: Double? + let priorityCacheReadInputCostPerToken: Double? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double?, + displayLabel: String?, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + priorityInputCostPerToken: Double? = nil, + priorityOutputCostPerToken: Double? = nil, + priorityCacheReadInputCostPerToken: Double? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.displayLabel = displayLabel + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.priorityInputCostPerToken = priorityInputCostPerToken + self.priorityOutputCostPerToken = priorityOutputCostPerToken + self.priorityCacheReadInputCostPerToken = priorityCacheReadInputCostPerToken + } } struct ClaudePricing { @@ -96,12 +132,22 @@ enum CostUsagePricing { inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 5e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 5e-7, + priorityInputCostPerToken: 5e-6, + priorityOutputCostPerToken: 3e-5, + priorityCacheReadInputCostPerToken: 5e-7), "gpt-5.4-mini": CodexPricing( inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, cacheReadInputCostPerToken: 7.5e-8, - displayLabel: nil), + displayLabel: nil, + priorityInputCostPerToken: 1.5e-6, + priorityOutputCostPerToken: 9e-6, + priorityCacheReadInputCostPerToken: 1.5e-7), "gpt-5.4-nano": CodexPricing( inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, @@ -116,7 +162,14 @@ enum CostUsagePricing { inputCostPerToken: 5e-6, outputCostPerToken: 3e-5, cacheReadInputCostPerToken: 5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 4.5e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6, + priorityInputCostPerToken: 1.25e-5, + priorityOutputCostPerToken: 7.5e-5, + priorityCacheReadInputCostPerToken: 1.25e-6), "gpt-5.5-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, @@ -257,6 +310,9 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let codexModelsDevProviderID = "openai" + private static let claudeModelsDevProviderID = "anthropic" + static func normalizeCodexModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("openai/") { @@ -310,15 +366,110 @@ enum CostUsagePricing { return trimmed } - static func codexCostUSD(model: String, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double? { + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { let key = self.normalizeCodexModel(model) + if let lookup = self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.codexCostUSD( + pricing: lookup.pricing, + thresholdTokens: self.codex[key]?.thresholdTokens, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + guard let pricing = self.codex[key] else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + static func codexPriorityCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int = 0, + outputTokens: Int) -> Double? + { + let key = self.normalizeCodexModel(model) + guard let pricing = self.codex[key], + let priorityInputCostPerToken = pricing.priorityInputCostPerToken, + let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken + else { return nil } + if max(0, inputTokens) > self.codexPriorityInputTokenLimit { + return nil + } + + let priorityPricing = CodexPricing( + inputCostPerToken: priorityInputCostPerToken, + outputCostPerToken: priorityOutputCostPerToken, + cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, + displayLabel: nil) + return self.codexCostUSD( + pricing: priorityPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + private static func codexCostUSD( + pricing: CodexPricing, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * cachedRate - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + + let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cachedInputRate) + + (Double(max(0, outputTokens)) * outputRate) + } + + private static func codexCostUSD( + pricing: ModelsDevPricingInfo, + thresholdTokens: Int? = nil, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { + self.codexCostUSD( + pricing: CodexPricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, + displayLabel: nil, + thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) } static func claudeCostUSD( @@ -326,11 +477,41 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, - outputTokens: Int) -> Double? + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? { + if let lookup = self.modelsDevLookup( + providerID: self.claudeModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: lookup.pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + let key = self.normalizeClaudeModel(model) guard let pricing = self.claude[key] else { return nil } + return self.claudeCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + private static func claudeCostUSD( + pricing: ClaudePricing, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int) -> Double + { func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { guard let threshold, let above else { return Double(tokens) * base } let below = min(tokens, threshold) @@ -359,4 +540,48 @@ enum CostUsagePricing { above: pricing.outputCostPerTokenAboveThreshold, threshold: pricing.thresholdTokens) } + + private static func claudeCostUSD( + pricing: ModelsDevPricingInfo, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int) -> Double + { + self.claudeCostUSD( + pricing: ClaudePricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheCreationInputCostPerToken: pricing.cacheCreationInputCostPerToken ?? pricing.inputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken, + thresholdTokens: pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + + static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + } + + private static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) + } + + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index c1ea28ed4..eae4d8bfc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -35,7 +35,9 @@ extension CostUsageScanner { fileURL: URL, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, - startOffset: Int64 = 0) -> ClaudeParseResult + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult { struct ClaudeTokens: Sendable { let input: Int @@ -43,6 +45,7 @@ extension CostUsageScanner { let cacheCreate: Int let output: Int let costNanos: Int + let costPriced: Bool } func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { @@ -50,12 +53,14 @@ extension CostUsageScanner { else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate packed[3] = (packed[safe: 3] ?? 0) + tokens.output packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) dayModels[normModel] = packed days[dayKey] = dayModels } @@ -91,72 +96,82 @@ extension CostUsageScanner { guard line.bytes.containsAscii(#""type":"assistant""#) else { return } guard line.bytes.containsAscii(#""usage""#) else { return } - 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) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 - let tokens = ClaudeTokens( - input: input, - cacheRead: cacheRead, - cacheCreate: cacheCreate, - output: output, - costNanos: costNanos) - - 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) - - // 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) + 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 @@ -168,7 +183,8 @@ extension CostUsageScanner { cacheRead: row.cacheRead, cacheCreate: row.cacheCreate, output: row.output, - costNanos: row.costNanos) + costNanos: row.costNanos, + costPriced: row.costPriced ?? (row.costNanos > 0)) add(dayKey: row.dayKey, model: row.model, tokens: tokens, days: &days) } @@ -180,10 +196,10 @@ extension CostUsageScanner { } private static func claudeCanonicalRowKey(_ row: ClaudeUsageRow) -> String? { - guard let sessionId = row.sessionId, let messageId = row.messageId, let requestId = row.requestId else { + guard let messageId = row.messageId, let requestId = row.requestId else { return nil } - return "\(sessionId):\(messageId):\(requestId)" + return "\(messageId):\(requestId)" } private static func mergeClaudeRows(existing: [ClaudeUsageRow], delta: [ClaudeUsageRow]) -> [ClaudeUsageRow] { @@ -232,12 +248,14 @@ extension CostUsageScanner { func addRow(_ row: ClaudeUsageRow) { var dayModels = days[row.dayKey] ?? [:] - var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + row.input packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate packed[3] = (packed[safe: 3] ?? 0) + row.output packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) dayModels[row.model] = packed days[row.dayKey] = dayModels } @@ -408,12 +426,22 @@ extension CostUsageScanner { var touched: Set let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter - - init(cache: CostUsageCache, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter) { + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + + init( + cache: CostUsageCache, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) + { self.cache = cache self.touched = [] self.range = range self.providerFilter = providerFilter + self.modelsDevCatalog = modelsDevCatalog + self.modelsDevCacheRoot = modelsDevCacheRoot } } @@ -442,7 +470,9 @@ extension CostUsageScanner { fileURL: url, range: state.range, providerFilter: state.providerFilter, - startOffset: startOffset) + startOffset: startOffset, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot) let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) state.cache.files[path] = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, @@ -456,7 +486,9 @@ extension CostUsageScanner { let parsed = Self.parseClaudeFile( fileURL: url, range: state.range, - providerFilter: state.providerFilter) + providerFilter: state.providerFilter, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot) let usage = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, @@ -545,7 +577,13 @@ extension CostUsageScanner { if options.forceRescan { cache = CostUsageCache() } - let scanState = ClaudeScanState(cache: cache, range: range, providerFilter: providerFilter) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let scanState = ClaudeScanState( + cache: cache, + range: range, + providerFilter: providerFilter, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) for root in roots { Self.scanClaudeRoot( @@ -567,12 +605,19 @@ extension CostUsageScanner { CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) } - return Self.buildClaudeReportFromCache(cache: cache, range: range) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + return Self.buildClaudeReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) } private static func buildClaudeReportFromCache( cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -608,6 +653,9 @@ extension CostUsageScanner { let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 let cachedCost = packed[safe: 4] ?? 0 + let sampleCount = packed[safe: 5] ?? 0 + let pricedSampleCount = packed[safe: 6] ?? 0 + let hasCompleteCachedCost = sampleCount > 0 && pricedSampleCount == sampleCount let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. @@ -616,14 +664,16 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let cost = cachedCost > 0 - ? Double(cachedCost) / costScale - : CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreate, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + // Cached costs are accumulated per request, which preserves Claude long-context threshold boundaries. + let cost = hasCompleteCachedCost ? Double(cachedCost) / costScale : currentPricingCost breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift new file mode 100644 index 000000000..4050ca8e4 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -0,0 +1,171 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +extension CostUsageScanner { + struct CodexPriorityTurnMetadata: Codable, Equatable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? + } + + private static let requestMarker = "websocket request:" + + static func defaultCodexPriorityDatabaseURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("logs_2.sqlite", isDirectory: false) + } + + static func codexPriorityTurns( + databaseURL: URL? = nil, + sinceDayKey: String? = nil, + untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] + { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + #if canImport(SQLite3) + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return [:] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = if sinceDayKey != nil || untilDayKey != nil { + """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? and feedback_log_body like '%websocket request:%' + """ + } else { + """ + select ts, feedback_log_body + from logs + where feedback_log_body like '%websocket request:%' + """ + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + if sinceDayKey != nil || untilDayKey != nil { + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) + } + + var turns: [String: CodexPriorityTurnMetadata] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + let timestamp = self.timestamp(stmt: stmt, index: 0) + guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), + let body = self.text(stmt: stmt, index: 1), + let parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + turns[parsed.turnID] = parsed + } + return turns + #else + return [:] + #endif + } + + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + guard let markerRange = body.range(of: self.requestMarker) else { return nil } + let prefix = String(body[.. String? { + guard let range = text.range(of: "\(name)=") else { return nil } + let tail = text[range.upperBound...] + let value = tail.prefix { char in + !char.isWhitespace && char != "," && char != "]" && char != ")" + } + return value.isEmpty ? nil : String(value) + } + + #if canImport(SQLite3) + private static func text(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { return nil } + return String(cString: cString) + } + + private static func timestamp(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + if sqlite3_column_type(stmt, index) == SQLITE_INTEGER { + return String(sqlite3_column_int64(stmt, index)) + } + return self.text(stmt: stmt, index: index) + } + #endif + + private static func timestamp(_ timestamp: String?, isInRangeSince since: String?, until: String?) -> Bool { + guard since != nil || until != nil else { return true } + guard let dayKey = self.dayKey(fromTimestamp: timestamp) else { return false } + if let since, dayKey < since { return false } + if let until, dayKey > until { return false } + return true + } + + private static func dayKey(fromTimestamp timestamp: String?) -> String? { + guard let timestamp else { return nil } + if let seconds = Int64(timestamp) { + return CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + let dayKey = timestamp.prefix(10) + return dayKey.count == 10 ? String(dayKey) : nil + } + + private static func nextDayKey(after dayKey: String) -> String { + guard let date = self.localDate(forDayKey: dayKey), + let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + else { return dayKey } + return CostUsageScanner.CostUsageDayRange.dayKey(from: next) + } + + private static func epochSeconds(forDayKey dayKey: String) -> Int64? { + guard let date = self.localDate(forDayKey: dayKey) else { return nil } + return Int64(date.timeIntervalSince1970) + } + + private static func localDate(forDayKey dayKey: String) -> Date? { + let parts = dayKey.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 components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + return components.date + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 69cc02db6..a8b75f766 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -14,6 +14,7 @@ enum CostUsageScanner { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? + var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -23,12 +24,14 @@ enum CostUsageScanner { codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, + codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot + self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } @@ -39,8 +42,19 @@ enum CostUsageScanner { let parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? + let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? + let rows: [CodexUsageRow] + } + + struct CodexUsageRow: Codable, Equatable { + let day: String + let model: String + let turnID: String? + let input: Int + let cached: Int + let output: Int } private struct CodexScanState { @@ -54,6 +68,52 @@ enum CostUsageScanner { let totals: CostUsageCodexTotals } + private static func codexTotalsEqual(_ lhs: CostUsageCodexTotals?, _ rhs: CostUsageCodexTotals?) -> Bool { + lhs?.input == rhs?.input && lhs?.cached == rhs?.cached && lhs?.output == rhs?.output + } + + private static func codexAddTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: lhs.input + rhs.input, + cached: lhs.cached + rhs.cached, + output: lhs.output + rhs.output) + } + + private static func codexTotalDelta( + from baseline: CostUsageCodexTotals?, + to current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) + return CostUsageCodexTotals( + input: max(0, current.input - baseline.input), + cached: max(0, current.cached - baseline.cached), + output: max(0, current.output - baseline.output)) + } + + private static func codexDivergentTotalDelta( + rawBaseline: CostUsageCodexTotals?, + countedBaseline: CostUsageCodexTotals?, + current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let rawBaseline = rawBaseline ?? .init(input: 0, cached: 0, output: 0) + let countedBaseline = countedBaseline ?? .init(input: 0, cached: 0, output: 0) + + func delta(raw: Int, counted: Int, current: Int) -> Int { + if current >= raw { + return max(0, current - raw) + } + return max(0, current - counted) + } + + return CostUsageCodexTotals( + input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), + cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + } + private struct CodexScanResources { let fileIndex: CodexSessionFileIndex let inheritedResolver: CodexInheritedTotalsResolver @@ -210,6 +270,7 @@ enum CostUsageScanner { let cacheCreate: Int let output: Int let costNanos: Int + let costPriced: Bool? } static func loadDailyReport( @@ -233,10 +294,10 @@ enum CostUsageScanner { filtered.claudeLogProviderFilter = .vertexAIOnly } return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, - .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, - .mistral: + case .openai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, + .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, + .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, + .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .deepgram: return emptyReport } } @@ -504,22 +565,25 @@ enum CostUsageScanner { func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { guard !lineData.isEmpty else { return nil } - guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] else { return nil } - guard obj["type"] as? String == "session_meta" else { return nil } - let payload = obj["payload"] as? [String: Any] - return CodexSessionMetadata( - sessionId: payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? 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, - forkTimestamp: payload?["timestamp"] as? String - ?? obj["timestamp"] as? String) + return autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] + else { return nil } + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? 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, + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String) + } } do { @@ -552,6 +616,8 @@ enum CostUsageScanner { { var sessionId: String? var previousTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals = false var snapshots: [CodexTimestampedTotals] = [] var warnedAboutUnparsedTimestamp = false @@ -574,54 +640,86 @@ enum CostUsageScanner { prefixBytes: 512 * 1024, onLine: { line in guard !line.bytes.isEmpty, !line.wasTruncated else { return } - guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] - else { return } - - if obj["type"] as? String == "session_meta" { - let payload = obj["payload"] as? [String: Any] - if sessionId == nil { - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String + autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] + else { return } + + if obj["type"] as? String == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + return } - return - } - guard obj["type"] as? String == "event_msg" else { return } - guard let payload = obj["payload"] as? [String: Any] else { return } - guard payload["type"] as? String == "token_count" else { return } - guard let info = payload["info"] as? [String: Any] else { return } - guard let timestamp = obj["timestamp"] as? String else { return } + guard obj["type"] as? String == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard payload["type"] as? String == "token_count" else { return } + guard let info = payload["info"] as? [String: Any] else { return } + guard let timestamp = obj["timestamp"] as? String else { return } - func toInt(_ value: Any?) -> Int { - if let number = value as? NSNumber { return number.intValue } - return 0 - } + func toInt(_ value: Any?) -> Int { + if let number = value as? NSNumber { return number.intValue } + return 0 + } - if let total = info["total_token_usage"] as? [String: Any] { - let next = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) - previousTotals = next - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: next)) - } else if let last = info["last_token_usage"] as? [String: Any] { - let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) - let next = CostUsageCodexTotals( - input: base.input + toInt(last["input_tokens"]), - cached: base.cached + toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"]), - output: base.output + toInt(last["output_tokens"])) - previousTotals = next - snapshots.append(CodexTimestampedTotals( - timestamp: timestamp, - date: parsedSnapshotDate(timestamp: timestamp), - totals: next)) + let total = info["total_token_usage"] as? [String: Any] + let last = info["last_token_usage"] as? [String: Any] + + if 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"])), + output: max(0, toInt(last["output_tokens"]))) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let next = Self.codexAddTotals(base, rawDelta) + previousTotals = next + + 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"])) + rawTotalsBaseline = rawTotals + if !Self.codexTotalsEqual(rawTotals, next) { + sawDivergentTotals = true + } + } else { + rawTotalsBaseline = next + } + + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: next)) + } else if let total { + let next = CostUsageCodexTotals( + input: toInt(total["input_tokens"]), + cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), + output: toInt(total["output_tokens"])) + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: next) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(base, delta) + previousTotals = countedTotals + rawTotalsBaseline = next + if !Self.codexTotalsEqual(next, countedTotals) { + sawDivergentTotals = true + } + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: countedTotals)) + } } }) } catch { @@ -640,6 +738,7 @@ enum CostUsageScanner { startOffset: Int64 = 0, initialModel: String? = nil, initialTotals: CostUsageCodexTotals? = nil, + initialCodexTurnID: String? = nil, inheritedTotalsResolver: ((String, String) -> CostUsageCodexTotals?)? = nil) -> CodexParseResult { var currentModel = initialModel @@ -648,8 +747,12 @@ enum CostUsageScanner { var forkedFromId: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? + var currentTurnID = initialCodexTurnID + var rawTotalsBaseline = initialTotals + var sawDivergentTotals = false var days: [String: [String: [Int]]] = [:] + var rows: [CodexUsageRow] = [] func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) @@ -699,145 +802,202 @@ enum CostUsageScanner { || line.bytes.containsAscii(#""type":"session_meta""#) else { return } - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { + if line.bytes.containsAscii(#""type":"event_msg""#), + !line.bytes.containsAscii(#""token_count""#), + !line.bytes.containsAscii(#""task_started""#) + { return } - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String - else { return } - - if type == "session_meta" { - let payload = obj["payload"] as? [String: Any] - if sessionId == nil { - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? 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 - } - if inheritedTotals == nil, let forkedFromId { - let forkedAt = payload?["timestamp"] as? String - ?? obj["timestamp"] as? String - ?? "" - inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) - remainingInheritedTotals = inheritedTotals + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String + else { return } + + if type == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? 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 + } + if inheritedTotals == nil, let forkedFromId { + let forkedAt = payload?["timestamp"] as? String + ?? obj["timestamp"] as? String + ?? "" + inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) + remainingInheritedTotals = inheritedTotals + } + return } - return - } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) - else { return } - if type == "turn_context" { - if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], - let model = info["model"] as? String - { - currentModel = model + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + if type == "turn_context" { + if let payload = obj["payload"] as? [String: Any] { + if let model = payload["model"] as? String { + currentModel = model + } else if let info = payload["info"] as? [String: Any], + let model = info["model"] as? String + { + currentModel = model + } } + return } - return - } - - guard type == "event_msg" else { return } - guard let payload = obj["payload"] as? [String: Any] else { return } - guard (payload["type"] as? String) == "token_count" else { return } - let info = payload["info"] as? [String: Any] - let modelFromInfo = info?["model"] as? String - ?? info?["model_name"] as? String - ?? payload["model"] as? String - ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" + guard type == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + if (payload["type"] as? String) == "task_started" { + currentTurnID = Self.codexTurnID(from: payload) + return + } + guard (payload["type"] as? String) == "token_count" else { return } + + let info = payload["info"] as? [String: Any] + let modelFromInfo = info?["model"] as? String + ?? info?["model_name"] as? String + ?? payload["model"] as? String + ?? obj["model"] as? String + let model = currentModel ?? modelFromInfo ?? "gpt-5" + + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } + let total = (info?["total_token_usage"] as? [String: Any]) + let last = (info?["last_token_usage"] as? [String: Any]) - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } - func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { - guard var remaining = remainingInheritedTotals else { return rawDelta } + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) - let adjusted = CostUsageCodexTotals( - input: max(0, rawDelta.input - remaining.input), - cached: max(0, rawDelta.cached - remaining.cached), - output: max(0, rawDelta.output - remaining.output)) + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } - remaining.input = max(0, remaining.input - rawDelta.input) - remaining.cached = max(0, remaining.cached - rawDelta.cached) - remaining.output = max(0, remaining.output - rawDelta.output) - remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, - remaining.output == 0 - { - nil - } else { - remaining + return adjusted } - return adjusted - } + if 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"])), + output: max(0, toInt(last["output_tokens"]))) + let adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + + 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"])) + 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 + } + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + 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"])) + + 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 + } - 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"])) - - 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)) + 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 { - rawTotals + return } - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - deltaInput = max(0, currentTotals.input - prev.input) - deltaCached = max(0, currentTotals.cached - prev.cached) - deltaOutput = max(0, currentTotals.output - prev.output) - previousTotals = currentTotals - remainingInheritedTotals = nil - } else if 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"])), - output: max(0, toInt(last["output_tokens"]))) - let adjustedDelta = adjustedLastDelta(rawDelta) - deltaInput = adjustedDelta.input - deltaCached = adjustedDelta.cached - deltaOutput = adjustedDelta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - previousTotals = CostUsageCodexTotals( - input: prev.input + deltaInput, - cached: prev.cached + deltaCached, - output: prev.output + deltaOutput) - } else { - return + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: Self.codexTurnID(from: payload) ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } } - - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) }) } catch { self.log.warning( @@ -850,9 +1010,23 @@ enum CostUsageScanner { days: days, parsedBytes: parsedBytes, lastModel: currentModel, - lastTotals: previousTotals, + lastTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) + ? nil + : previousTotals, + lastCodexTurnID: currentTurnID, sessionId: sessionId, - forkedFromId: forkedFromId) + forkedFromId: forkedFromId, + rows: rows) + } + + private static func codexTurnID(from payload: [String: Any]) -> String? { + if let turnID = payload["turn_id"] as? String ?? payload["turnId"] as? String ?? payload["id"] as? String { + return turnID + } + if let info = payload["info"] as? [String: Any] { + return info["turn_id"] as? String ?? info["turnId"] as? String ?? info["id"] as? String + } + return nil } private static func scanCodexFile( @@ -913,7 +1087,8 @@ enum CostUsageScanner { range: range, startOffset: startOffset, initialModel: cached.lastModel, - initialTotals: cached.lastTotals) + initialTotals: cached.lastTotals, + initialCodexTurnID: cached.lastCodexTurnID) let sessionId = delta.sessionId ?? cached.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { dropCachedFile(cached) @@ -933,8 +1108,10 @@ enum CostUsageScanner { parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, lastTotals: delta.lastTotals, + lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, - forkedFromId: delta.forkedFromId ?? cached.forkedFromId) + forkedFromId: delta.forkedFromId ?? cached.forkedFromId, + codexRows: (cached.codexRows ?? []) + delta.rows) if let sessionId { state.seenSessionIds.insert(sessionId) resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) @@ -967,8 +1144,10 @@ enum CostUsageScanner { parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, lastTotals: parsed.lastTotals, + lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, - forkedFromId: parsed.forkedFromId) + forkedFromId: parsed.forkedFromId, + codexRows: parsed.rows) cache.files[path] = usage Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) if let sessionId { @@ -985,7 +1164,11 @@ enum CostUsageScanner { let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) + let roots = self.codexSessionsRoots(options: options) + let rootsFingerprint = Self.codexRootsFingerprint(roots) + let rootsChanged = cache.roots != rootsFingerprint let shouldRefresh = options.forceRescan + || rootsChanged || refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs @@ -995,10 +1178,7 @@ enum CostUsageScanner { cache = CostUsageCache() } - let roots = self.codexSessionsRoots(options: options) let includeRecursive = options.forceRescan - let rootsFingerprint = Self.codexRootsFingerprint(roots) - let rootsChanged = cache.roots != nil && cache.roots != rootsFingerprint let shouldRunColdCacheLookback = cache.files.isEmpty || rootsChanged let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) .map { Calendar.current.startOfDay(for: $0) } @@ -1069,12 +1249,25 @@ enum CostUsageScanner { CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) } - return Self.buildCodexReportFromCache(cache: cache, range: range) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let priorityTurns = Self.codexPriorityTurns( + databaseURL: options.codexTraceDatabaseURL, + sinceDayKey: range.sinceKey, + untilDayKey: range.untilKey) + return Self.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: priorityTurns) } private static func buildCodexReportFromCache( cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -1086,6 +1279,7 @@ enum CostUsageScanner { let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) } + let rowsByDayModel = self.codexRowsByDayModel(cache: cache, range: range) for day in dayKeys { guard let models = cache.days[day] else { continue } @@ -1108,11 +1302,29 @@ enum CostUsageScanner { dayInput += input dayOutput += output - let cost = CostUsagePricing.codexCostUSD( + let rows = rowsByDayModel[day]?[model] + var cost = rows.flatMap { + self.codexRowsCostUSD( + rows: $0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, - outputTokens: output) + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if !priorityTurns.isEmpty, + let rows, + let surcharge = self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + cost = (cost ?? 0) + surcharge + } breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, @@ -1157,6 +1369,72 @@ enum CostUsageScanner { return CostUsageDailyReport(data: entries, summary: summary) } + private static func codexRowsByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for usage in cache.files.values { + for row in usage.codexRows ?? [] { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + } + return rowsByDayModel + } + + private static func codexRowsCostUSD( + rows: [CodexUsageRow], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let cost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + private static func codexPrioritySurchargeUSD( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let turnID = row.turnID, priorityTurns[turnID] != nil else { continue } + guard let baseCost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + else { continue } + total += max(priorityCost - baseCost, 0) + seen = true + } + return seen ? total : nil + } + // MARK: - Shared cache mutations static func makeFileUsage( @@ -1166,8 +1444,10 @@ enum CostUsageScanner { parsedBytes: Int64?, lastModel: String? = nil, lastTotals: CostUsageCodexTotals? = nil, + lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + codexRows: [CodexUsageRow]? = nil, claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( @@ -1177,8 +1457,10 @@ enum CostUsageScanner { parsedBytes: parsedBytes, lastModel: lastModel, lastTotals: lastTotals, + lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + codexRows: codexRows, claudeRows: claudeRows) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift new file mode 100644 index 000000000..87801eb67 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -0,0 +1,473 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct ModelsDevPricingInfo: Codable, Equatable { + var providerID: String + var providerName: String? + var modelID: String + var modelName: String? + var inputCostPerToken: Double + var outputCostPerToken: Double + var cacheReadInputCostPerToken: Double? + var cacheCreationInputCostPerToken: Double? + var contextWindow: Int? + var thresholdTokens: Int? + var inputCostPerTokenAboveThreshold: Double? + var outputCostPerTokenAboveThreshold: Double? + var cacheReadInputCostPerTokenAboveThreshold: Double? + var cacheCreationInputCostPerTokenAboveThreshold: Double? +} + +struct ModelsDevPricingLookup: Equatable { + var pricing: ModelsDevPricingInfo + var normalizedModelID: String +} + +struct ModelsDevCatalog: Codable, Equatable { + var providers: [String: ModelsDevProvider] + + init(providers: [String: ModelsDevProvider]) { + self.providers = providers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ModelsDevAnyCodingKey.self) + if let providersKey = ModelsDevAnyCodingKey(stringValue: "providers"), + let decoded = try? container.decode([String: ModelsDevProvider].self, forKey: providersKey) + { + self.providers = decoded.reduce(into: [:]) { result, item in + var provider = item.value + provider.mapKey = provider.mapKey ?? item.key + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? item.key) + result[providerID] = provider + } + return + } + + var providers: [String: ModelsDevProvider] = [:] + + for key in container.allKeys { + guard var provider = try? container.decode(ModelsDevProvider.self, forKey: key) else { continue } + provider.mapKey = key.stringValue + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? key.stringValue) + providers[providerID] = provider + } + + self.providers = providers + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ModelsDevAnyCodingKey.self) + try container.encode(self.providers, forKey: ModelsDevAnyCodingKey(stringValue: "providers")!) + } + + func pricing(providerID rawProviderID: String, modelID rawModelID: String) -> ModelsDevPricingLookup? { + let providerID = ModelsDevProvider.normalizeProviderID(rawProviderID) + return self.providers[providerID]?.pricing(modelID: rawModelID) + } + + func containsProviderIDs(_ providerIDs: some Sequence) -> Bool { + providerIDs.allSatisfy { self.providers.keys.contains(ModelsDevProvider.normalizeProviderID($0)) } + } + + func containsProviderModels(from cachedCatalog: ModelsDevCatalog) -> Bool { + cachedCatalog.providers.allSatisfy { providerID, cachedProvider in + guard let provider = self.providers[ModelsDevProvider.normalizeProviderID(providerID)] else { return false } + return cachedProvider.models.values + .filter(\.isPriceable) + .allSatisfy { provider.containsModel(matching: $0) } + } + } +} + +private struct ModelsDevAnyCodingKey: CodingKey { + var intValue: Int? + var stringValue: String + + init?(intValue: Int) { + self.intValue = intValue + self.stringValue = String(intValue) + } + + init?(stringValue: String) { + self.intValue = nil + self.stringValue = stringValue + } +} + +struct ModelsDevProvider: Codable, Equatable { + var id: String? + var name: String? + var models: [String: ModelsDevModel] + var mapKey: String? + + private enum CodingKeys: String, CodingKey { + case id + case name + case models + } + + init(id: String?, name: String?, models: [String: ModelsDevModel], mapKey: String? = nil) { + self.id = id + self.name = name + self.models = models + self.mapKey = mapKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + let modelContainer = try container.nestedContainer(keyedBy: ModelsDevAnyCodingKey.self, forKey: .models) + var models: [String: ModelsDevModel] = [:] + for key in modelContainer.allKeys { + guard let model = try? modelContainer.decode(ModelsDevModel.self, forKey: key) else { continue } + models[key.stringValue] = model + } + self.models = models + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.id, forKey: .id) + try container.encodeIfPresent(self.name, forKey: .name) + try container.encode(self.models, forKey: .models) + } + + static func normalizeProviderID(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + func pricing(modelID rawModelID: String) -> ModelsDevPricingLookup? { + let candidates = ModelsDevModelIDNormalizer.candidates(rawModelID) + for candidate in candidates { + if let model = self.models[candidate], + let pricing = model.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) + } + } + + for candidate in candidates { + if let match = self.models.values.first(where: { $0.normalizedID == candidate }), + let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + } + } + + return nil + } + + func containsModel(matching cachedModel: ModelsDevModel) -> Bool { + self.pricing(modelID: cachedModel.id) != nil + } +} + +struct ModelsDevModel: Codable, Equatable { + var id: String + var name: String? + var cost: ModelsDevCost? + var limit: ModelsDevLimit? + + var normalizedID: String { + ModelsDevModelIDNormalizer.normalize(self.id) + } + + var isPriceable: Bool { + self.cost?.input != nil && self.cost?.output != nil + } + + func pricing(providerID: String, providerName: String?) -> ModelsDevPricingInfo? { + guard let input = self.cost?.input, let output = self.cost?.output else { return nil } + + // models.dev publishes USD per 1M tokens. CodexBar cost math uses USD per token. + let unit = 1_000_000.0 + let contextOver200K = self.cost?.contextOver200K + return ModelsDevPricingInfo( + providerID: ModelsDevProvider.normalizeProviderID(providerID), + providerName: providerName, + modelID: self.id, + modelName: self.name, + inputCostPerToken: input / unit, + outputCostPerToken: output / unit, + cacheReadInputCostPerToken: self.cost?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerToken: self.cost?.cacheWrite.map { $0 / unit }, + contextWindow: self.limit?.context, + thresholdTokens: contextOver200K == nil ? nil : 200_000, + inputCostPerTokenAboveThreshold: contextOver200K?.input.map { $0 / unit }, + outputCostPerTokenAboveThreshold: contextOver200K?.output.map { $0 / unit }, + cacheReadInputCostPerTokenAboveThreshold: contextOver200K?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerTokenAboveThreshold: contextOver200K?.cacheWrite.map { $0 / unit }) + } +} + +struct ModelsDevCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + var contextOver200K: ModelsDevContextOver200KCost? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + case contextOver200K = "context_over_200k" + } +} + +struct ModelsDevContextOver200KCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + } +} + +struct ModelsDevLimit: Codable, Equatable { + var context: Int? +} + +enum ModelsDevModelIDNormalizer { + static func normalize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func candidates(_ raw: String) -> [String] { + var candidates: [String] = [] + + func append(_ value: String) { + let normalized = self.normalize(value) + guard !normalized.isEmpty, !candidates.contains(normalized) else { return } + candidates.append(normalized) + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + append(trimmed) + + if trimmed.hasPrefix("openai/") { + append(String(trimmed.dropFirst("openai/".count))) + } + + if trimmed.hasPrefix("anthropic.") { + append(String(trimmed.dropFirst("anthropic.".count))) + } + + if let lastDot = trimmed.lastIndex(of: "."), + trimmed.contains("claude-") + { + let tail = String(trimmed[trimmed.index(after: lastDot)...]) + if tail.hasPrefix("claude-") { + append(tail) + } + } + + var index = 0 + while index < candidates.count { + let candidate = candidates[index] + if let atSign = candidate.firstIndex(of: "@") { + let base = String(candidate[.. URL { + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("model-pricing", isDirectory: true) + .appendingPathComponent("models-dev-v\(Self.artifactVersion).json", isDirectory: false) + } + + static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard let data = try? Data(contentsOf: url) else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .unreadable) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidJSON) + } + guard decoded.version == Self.artifactVersion else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidVersion) + } + + return ModelsDevCacheLoadResult( + artifact: decoded, + isStale: now.timeIntervalSince(decoded.fetchedAt) > Self.ttlSeconds, + error: nil) + } + + static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { + let artifact = ModelsDevCacheArtifact( + version: Self.artifactVersion, + fetchedAt: fetchedAt, + catalog: catalog) + self.save(artifact: artifact, cacheRoot: cacheRoot) + } + + static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(artifact) else { return } + + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) + do { + try data.write(to: tmp, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } +} + +protocol ModelsDevHTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +struct URLSessionModelsDevTransport: ModelsDevHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await URLSession.shared.data(for: request) + } +} + +struct ModelsDevClient { + enum Error: Swift.Error, Equatable { + case invalidResponse + case httpStatus(Int) + case invalidJSON + } + + var url: URL + var transport: any ModelsDevHTTPTransport + + init( + url: URL = URL(string: "https://models.dev/api.json")!, + transport: any ModelsDevHTTPTransport = URLSessionModelsDevTransport()) + { + self.url = url + self.transport = transport + } + + func fetchCatalog() async throws -> ModelsDevCatalog { + var request = URLRequest(url: self.url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + + let (data, response) = try await self.transport.data(for: request) + guard let http = response as? HTTPURLResponse else { throw Error.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw Error.httpStatus(http.statusCode) } + + do { + return try JSONDecoder().decode(ModelsDevCatalog.self, from: data) + } catch { + throw Error.invalidJSON + } + } +} + +enum ModelsDevPricingPipeline { + static func lookup( + providerID: String, + modelID: String, + now: Date = Date(), + cacheRoot: URL? = nil) -> ModelsDevPricingLookup? + { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + .artifact? + .catalog + .pricing(providerID: providerID, modelID: modelID) + } + + static func refreshIfNeeded( + now: Date = Date(), + cacheRoot: URL? = nil, + client: ModelsDevClient = ModelsDevClient()) async + { + let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + guard load.isStale else { return } + + do { + let catalog = try await client.fetchCatalog() + if let oldCatalog = load.artifact?.catalog, + !catalog.containsProviderModels(from: oldCatalog) + { + return + } + ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: cacheRoot) + } catch { + // Best-effort refresh only. Future scanner integration should keep using the last valid cache. + } + } +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index ce60108de..adba27585 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -52,6 +52,7 @@ enum ProviderChoice: String, AppEnum { init?(provider: UsageProvider) { switch provider { case .codex: self = .codex + case .openai: return nil // OpenAI not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini case .alibaba: self = .alibaba @@ -63,6 +64,7 @@ enum ProviderChoice: String, AppEnum { case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot case .minimax: self = .minimax + case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets case .kilo: self = .kilo case .kiro: return nil // Kiro not yet supported in widgets @@ -70,14 +72,28 @@ enum ProviderChoice: String, AppEnum { case .jetbrains: return nil // JetBrains not yet supported in widgets case .kimi: return nil // Kimi not yet supported in widgets case .kimik2: return nil // Kimi K2 not yet supported in widgets + case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets + case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .windsurf: return nil // Windsurf not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets + case .mimo: return nil // Xiaomi MiMo not yet supported in widgets + case .doubao: return nil // Doubao not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets case .mistral: return nil // Mistral not yet supported in widgets + case .deepseek: return nil // DeepSeek not yet supported in widgets + case .codebuff: return nil // Codebuff not yet supported in widgets + case .crof: return nil // Crof not yet supported in widgets + case .venice: return nil // Venice not yet supported in widgets + case .commandcode: return nil // CommandCode not yet supported in widgets + case .stepfun: return nil // StepFun not yet supported in widgets + case .bedrock: return nil // Bedrock not yet supported in widgets + case .grok: return nil // Grok not yet supported in widgets + case .deepgram: return nil // Deepgram not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 00c791aa8..33d0aa003 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -258,6 +258,7 @@ private struct ProviderSwitchChip: View { private var shortLabel: String { switch self.provider { case .codex: "Codex" + case .openai: "OpenAI" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" @@ -269,6 +270,7 @@ private struct ProviderSwitchChip: View { case .factory: "Droid" case .copilot: "Copilot" case .minimax: "MiniMax" + case .manus: "Manus" case .vertexai: "Vertex" case .kilo: "Kilo" case .kiro: "Kiro" @@ -276,14 +278,28 @@ private struct ProviderSwitchChip: View { case .jetbrains: "JetBrains" case .kimi: "Kimi" case .kimik2: "Kimi K2" + case .moonshot: "Moonshot" case .amp: "Amp" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" + case .elevenlabs: "ElevenLabs" case .warp: "Warp" + case .windsurf: "Windsurf" case .perplexity: "Pplx" + case .mimo: "MiMo" + case .doubao: "Doubao" case .abacus: "Abacus" case .mistral: "Mistral" + case .deepseek: "DeepSeek" + case .codebuff: "Codebuff" + case .crof: "Crof" + case .venice: "Venice" + case .commandcode: "Command Code" + case .stepfun: "StepFun" + case .bedrock: "Bedrock" + case .grok: "Grok" + case .deepgram: "Deepgram" } } } @@ -595,6 +611,8 @@ enum WidgetColors { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .openai: + Color(red: 15 / 255, green: 130 / 255, blue: 110 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: @@ -617,6 +635,8 @@ enum WidgetColors { Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) + case .manus: + Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255) case .vertexai: Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kilo: @@ -631,6 +651,8 @@ enum WidgetColors { Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange case .kimik2: Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple + case .moonshot: + Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255) case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .ollama: @@ -639,14 +661,40 @@ enum WidgetColors { Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .elevenlabs: + Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .windsurf: + Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .mimo: + Color(red: 1.0, green: 105 / 255, blue: 0) + case .doubao: + Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue case .abacus: Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange + case .deepseek: + Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) + case .codebuff: + Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime + case .crof: + Color(red: 46 / 255, green: 171 / 255, blue: 148 / 255) + case .venice: + Color(red: 51 / 255, green: 153 / 255, blue: 1.0) + case .commandcode: + Color(red: 0, green: 0, blue: 0) + case .stepfun: + Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange + case .bedrock: + Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange + case .grok: + Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) // Grok teal + case .deepgram: + Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) } } } @@ -692,6 +740,7 @@ enum WidgetFormat { static func relativeDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift index a013df8f4..25dc0537b 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -9,6 +9,28 @@ struct AlibabaCodingPlanSettingsReaderTests { #expect(token == "abc123") } + @Test + func `api token reads qwen alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["ALIBABA_QWEN_API_KEY": "qwen123"]) + #expect(token == "qwen123") + } + + @Test + func `api token reads dashscope alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["DASHSCOPE_API_KEY": "dashscope123"]) + #expect(token == "dashscope123") + } + + @Test + func `api token prefers coding plan key over aliases`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: [ + "ALIBABA_CODING_PLAN_API_KEY": "coding-plan", + "ALIBABA_QWEN_API_KEY": "qwen", + "DASHSCOPE_API_KEY": "dashscope", + ]) + #expect(token == "coding-plan") + } + @Test func `api token strips quotes`() { let token = AlibabaCodingPlanSettingsReader @@ -462,7 +484,7 @@ struct AlibabaCodingPlanUsageParsingTests { } @Test - func `console need login payload maps to api error for API key mode`() { + func `console need login payload maps to unavailable API key mode`() { let json = """ { "code": "ConsoleNeedLogin", @@ -472,19 +494,10 @@ struct AlibabaCodingPlanUsageParsingTests { } """ - do { - _ = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot( + #expect(throws: AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot( from: Data(json.utf8), authMode: .apiKey) - Issue.record("Expected API-mode ConsoleNeedLogin payload to throw") - } catch let error as AlibabaCodingPlanUsageError { - guard case let .apiError(message) = error else { - Issue.record("Expected apiError, got \(error)") - return - } - #expect(message.contains("requires a console session")) - } catch { - Issue.record("Expected AlibabaCodingPlanUsageError, got \(error)") } } } diff --git a/Tests/CodexBarTests/AntigravityLoginAlertTests.swift b/Tests/CodexBarTests/AntigravityLoginAlertTests.swift new file mode 100644 index 000000000..a11ee63a7 --- /dev/null +++ b/Tests/CodexBarTests/AntigravityLoginAlertTests.swift @@ -0,0 +1,52 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct AntigravityLoginAlertTests { + @Test + func `authorization URL asks Google to select an account`() throws { + let redirectURL = try #require(URL(string: "http://127.0.0.1:54321/callback")) + let url = try AntigravityLoginRunner.makeAuthorizationURL( + redirectURL: redirectURL, + state: "state", + oauthClient: AntigravityOAuthClient( + clientID: "client.apps.googleusercontent.com", + clientSecret: "secret")) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let prompt = components.queryItems?.first(where: { $0.name == "prompt" })?.value + + #expect(prompt?.split(separator: " ").contains("select_account") == true) + #expect(prompt?.split(separator: " ").contains("consent") == true) + } + + @Test + func `returns alert for timeout`() { + let result = AntigravityLoginRunner.Result(outcome: .timedOut) + let info = StatusItemController.antigravityLoginAlertInfo(for: result) + #expect(info?.title == "Antigravity login timed out") + } + + @Test + func `returns alert for launch failure`() { + let result = AntigravityLoginRunner.Result(outcome: .launchFailed("https://example.com/login")) + let info = StatusItemController.antigravityLoginAlertInfo(for: result) + #expect(info?.title == "Could not open browser for Antigravity") + #expect(info?.message.contains("https://example.com/login") == true) + } + + @Test + func `returns alert for auth failure`() { + let result = AntigravityLoginRunner.Result(outcome: .failed("permission denied")) + let info = StatusItemController.antigravityLoginAlertInfo(for: result) + #expect(info?.title == "Antigravity login failed") + #expect(info?.message == "permission denied") + } + + @Test + func `returns nil on success`() { + let result = AntigravityLoginRunner.Result(outcome: .success("user@example.com")) + let info = StatusItemController.antigravityLoginAlertInfo(for: result) + #expect(info == nil) + } +} diff --git a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift new file mode 100644 index 000000000..7772818aa --- /dev/null +++ b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift @@ -0,0 +1,851 @@ +import CodexBarCore +import Foundation +import Testing + +private actor AntigravityCredentialUpdateCapture { + private var captured: [AntigravityOAuthCredentials] = [] + + func append(_ credentials: AntigravityOAuthCredentials) { + self.captured.append(credentials) + } + + func values() -> [AntigravityOAuthCredentials] { + self.captured + } +} + +@Suite(.serialized) +struct AntigravityRemoteUsageFetcherTests { + @Test + func `antigravity supports token accounts for quick account switching`() { + let support = TokenAccountSupportCatalog.support(for: .antigravity) + + #expect(support?.title == "Google accounts") + #expect(support?.requiresManualCookieSource == false) + #expect(TokenAccountSupportCatalog.envOverride( + for: .antigravity, + token: "serialized-credentials")?[AntigravityOAuthCredentialsStore.environmentCredentialsKey] == + "serialized-credentials") + } + + @Test + func `oauth credentials round trip through token account value`() throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com", + projectID: "project-123", + clientID: "client-id", + clientSecret: "client-secret") + + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: token)) + + #expect(decoded == credentials) + } + + @Test + func `remote fetch uses selected token account credentials before shared credentials`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com") + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-token", + refreshToken: nil, + expiryDate: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected@example.com"), + email: "selected@example.com", + projectID: nil, + clientID: nil, + clientSecret: nil) + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer selected-token") + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + + #expect(snapshot.accountEmail == "selected@example.com") + } + + @Test + func `remote fetch refreshes selected token account without mutating shared credentials`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: "shared-refresh", + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com", + clientID: "shared-client-id", + clientSecret: "shared-client-secret") + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-old-token", + refreshToken: "selected-refresh", + expiryDate: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected-old@example.com"), + email: "selected-old@example.com", + projectID: nil, + clientID: "selected-client-id", + clientSecret: "selected-client-secret") + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + let updateCapture = AntigravityCredentialUpdateCapture() + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" + #expect(body.contains("client_id=selected-client-id")) + #expect(body.contains("refresh_token=selected-refresh")) + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "access_token": "selected-new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "selected-new@example.com"), + ])) + case "cloudcode-pa.googleapis.com": + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer selected-new-token") + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "selected-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader, + credentialsUpdateHandler: { credentials in + await updateCapture.append(credentials) + }) + let snapshot = try await fetcher.fetch() + let shared = try env.readAntigravityCredentials() + let updatedCredentials = await updateCapture.values() + + #expect(snapshot.accountEmail == "selected-new@example.com") + #expect(shared["access_token"] as? String == "shared-token") + #expect(shared["email"] as? String == "shared@example.com") + #expect(updatedCredentials.last?.accessToken == "selected-new-token") + #expect(updatedCredentials.last?.projectID == "selected-project-123") + } + + @Test + func `remote fetch ignores selected token account project id persistence failure`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-token", + refreshToken: nil, + expiryDate: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected@example.com"), + email: "selected@example.com", + projectID: nil, + clientID: nil, + clientSecret: nil) + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "selected-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader, + credentialsUpdateHandler: { _ in + throw CocoaError(.fileWriteUnknown) + }) + let snapshot = try await fetcher.fetch() + + #expect(snapshot.accountEmail == "selected@example.com") + } + + @Test + func `remote fetch rejects invalid selected token account`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com") + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: "not-json"], + dataLoader: GeminiAPITestHelpers.dataLoader { _ in + throw URLError(.badServerResponse) + }) + + do { + _ = try await fetcher.fetch() + #expect(Bool(false), "Expected selected account decode failure") + } catch let error as AntigravityRemoteFetchError { + guard case let .parseFailed(message) = error else { + #expect(Bool(false), "Unexpected Antigravity error: \(error)") + return + } + #expect(message.contains("selected account")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `remote fetch maps cloud code models into antigravity usage`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@company.com", hostedDomain: "company.com"), + email: "user@company.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["project"] as? String == "managed-project-123") + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + + #expect(snapshot.accountEmail == "user@company.com") + #expect(snapshot.accountPlan == "Paid") + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 50) + #expect(usage.secondary?.remainingPercent.rounded() == 80) + #expect(usage.tertiary?.remainingPercent.rounded() == 20) + } + + @Test + func `remote fetch refreshes expired shared google token`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "stale@example.com"), + email: "stale@example.com", + clientID: "test-client-id", + clientSecret: "test-client-secret") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "refreshed@example.com"), + ])) + case "cloudcode-pa.googleapis.com": + let auth = request.value(forHTTPHeaderField: "Authorization") + #expect(auth == "Bearer new-token") + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 2, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + + let updated = try env.readAntigravityCredentials() + #expect(updated["access_token"] as? String == "new-token") + #expect(snapshot.accountEmail == "refreshed@example.com") + } + + @Test + func `remote fetch refreshes nearly expired shared google token`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(5), + idToken: GeminiAPITestHelpers.makeIDToken(email: "stale@example.com"), + email: "stale@example.com", + clientID: "test-client-id", + clientSecret: "test-client-secret") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "refreshed@example.com"), + ])) + case "cloudcode-pa.googleapis.com": + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer new-token") + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 2, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + + let updated = try env.readAntigravityCredentials() + #expect(updated["access_token"] as? String == "new-token") + #expect(snapshot.accountEmail == "refreshed@example.com") + } + + @Test + func `remote refresh requires configured oauth client`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: GeminiAPITestHelpers.dataLoader { _ in + throw URLError(.badServerResponse) + }, + oauthClientResolver: { nil }) + + do { + _ = try await fetcher.fetch() + #expect(Bool(false), "Expected missing OAuth client configuration error") + } catch let error as AntigravityRemoteFetchError { + guard case let .apiError(message) = error else { + #expect(Bool(false), "Unexpected Antigravity error: \(error)") + return + } + #expect(message.contains("ANTIGRAVITY_OAUTH_CLIENT_ID")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `remote fetch onboards project before fetching models`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var projects: [String] = [] + + func append(_ value: String) { + self.lock.lock() + self.projects.append(value) + self.lock.unlock() + } + + func last() -> String? { + self.lock.lock() + defer { self.lock.unlock() } + return self.projects.last + } + } + + let recorder = Recorder() + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "allowedTiers": [["id": "standard-tier", "isDefault": true]], + ])) + } + if url.path == "/v1internal:onboardUser" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "response": [ + "cloudaicompanionProject": [ + "id": "onboarded-project-456", + ], + ], + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + if let project = json["project"] as? String { + recorder.append(project) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + _ = try await fetcher.fetch() + + #expect(recorder.last() == "onboarded-project-456") + } + + @Test + func `remote fetch falls back to retrieve user quota when model endpoint is forbidden`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() { + self.lock.lock() + self.value += 1 + self.lock.unlock() + } + + func get() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + let quotaCalls = Counter() + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.jsonData([ + "error": [ + "code": 403, + "message": "The caller does not have permission", + "status": "PERMISSION_DENIED", + ], + ])) + } + if url.path == "/v1internal:retrieveUserQuota" { + quotaCalls.increment() + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + let usage = try snapshot.toUsageSnapshot() + + #expect(quotaCalls.get() == 1) + #expect(usage.secondary?.remainingPercent == 60.0) + #expect(usage.tertiary?.remainingPercent == 90.0) + } + + @Test + func `antigravity descriptor advertises oauth mode`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .antigravity) + #expect(descriptor.fetchPlan.sourceModes == [.auto, .cli, .oauth]) + } + + @Test + func `remote fetch returns identity when both remote quota endpoints are forbidden`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com") + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" || url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: GeminiAPITestHelpers.jsonData([ + "error": [ + "code": 403, + "message": "The caller does not have permission", + "status": "PERMISSION_DENIED", + ], + ])) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let snapshot = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + + #expect(snapshot.modelQuotas.isEmpty) + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `remote fetch ignores gemini credentials when antigravity auth is missing`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "gemini-token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "gemini@example.com")) + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: GeminiAPITestHelpers.dataLoader { _ in + throw URLError(.badServerResponse) + }) + + await #expect(throws: AntigravityRemoteFetchError.notLoggedIn) { + try await fetcher.fetch() + } + } + + @Test + func `remote fetch prefers stored project id from antigravity credentials`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com", + projectID: "stored-project-789") + + final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var projects: [String] = [] + + func append(_ value: String) { + self.lock.lock() + self.projects.append(value) + self.lock.unlock() + } + + func last() -> String? { + self.lock.lock() + defer { self.lock.unlock() } + return self.projects.last + } + } + + let recorder = Recorder() + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "standard-tier", "name": "standard"], + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + if let project = json["project"] as? String { + recorder.append(project) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + _ = try await AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + dataLoader: dataLoader) + .fetch() + + #expect(recorder.last() == "stored-project-789") + } + + private static func availableModelsResponse() -> Data { + GeminiAPITestHelpers.jsonData([ + "models": [ + "claude-sonnet-4": [ + "displayName": "Claude Sonnet 4", + "quotaInfo": [ + "remainingFraction": 0.5, + "resetTime": "2025-01-01T00:00:00Z", + ], + ], + "gemini-3-pro-low": [ + "displayName": "Gemini 3 Pro Low", + "quotaInfo": [ + "remainingFraction": 0.8, + "resetTime": "2025-01-01T00:00:00Z", + ], + ], + "gemini-3-flash": [ + "displayName": "Gemini 3 Flash", + "quotaInfo": [ + "remainingFraction": 0.2, + "resetTime": "2025-01-01T00:00:00Z", + ], + ], + "gemini-3-flash-lite": [ + "displayName": "Gemini 3 Flash Lite", + "quotaInfo": [ + "remainingFraction": 0.7, + "resetTime": "2025-01-01T00:00:00Z", + ], + ], + ], + ]) + } +} diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 5e91fc46e..467026f93 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -628,6 +628,30 @@ struct AntigravityStatusProbeTests { #expect(usage.secondary?.remainingPercent.rounded() == 90) } + @Test + func `gemini pro prefers model with remaining data over low priority placeholder`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: nil, + resetTime: Date(timeIntervalSince1970: 1_735_000_000), + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M37", + remainingFraction: 1, + resetTime: Date(timeIntervalSince1970: 1_735_100_000), + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent.rounded() == 100) + } + @Test func `gemini flash does not fallback to lite variant`() throws { let snapshot = AntigravityStatusSnapshot( @@ -685,6 +709,58 @@ struct AntigravityStatusProbeTests { #expect(usage.tertiary?.remainingPercent.rounded() == 100) } + @Test + func `matches remote antigravity model names with parentheses`() throws { + let resetTime = Date(timeIntervalSince1970: 1_775_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M50", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M51", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M53", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "MODEL_PLACEHOLDER_M54", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: "user@example.com", + accountPlan: "Pro") + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 100) + #expect(usage.secondary?.remainingPercent.rounded() == 100) + #expect(usage.tertiary?.remainingPercent.rounded() == 100) + #expect(usage.identity?.accountEmail == "user@example.com") + } + @Test func `model without remaining fraction keeps reset time`() throws { let resetTime = Date(timeIntervalSince1970: 1_735_000_000) diff --git a/Tests/CodexBarTests/AppDelegateTests.swift b/Tests/CodexBarTests/AppDelegateTests.swift index 9b483b3eb..5cee17436 100644 --- a/Tests/CodexBarTests/AppDelegateTests.swift +++ b/Tests/CodexBarTests/AppDelegateTests.swift @@ -55,4 +55,5 @@ struct AppDelegateTests { @MainActor private final class DummyStatusController: StatusItemControlling { func openMenuFromShortcut() {} + func runLoginFlowFromSettings(provider _: UsageProvider) async {} } diff --git a/Tests/CodexBarTests/AppNotificationsTests.swift b/Tests/CodexBarTests/AppNotificationsTests.swift new file mode 100644 index 000000000..a78659900 --- /dev/null +++ b/Tests/CodexBarTests/AppNotificationsTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@preconcurrency import UserNotifications +@testable import CodexBar + +@MainActor +struct AppNotificationsTests { + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var requests: [UNNotificationRequest] = [] + private var sounds: [(sound: NotificationSoundOption, volume: Double)] = [] + + func record(request: UNNotificationRequest) { + self.lock.withLock { + self.requests.append(request) + } + } + + func record(sound: NotificationSoundOption, volume: Double) { + self.lock.withLock { + self.sounds.append((sound: sound, volume: volume)) + } + } + + func requestCount() -> Int { + self.lock.withLock { + self.requests.count + } + } + + func soundsSnapshot() -> [(sound: NotificationSoundOption, volume: Double)] { + self.lock.withLock { + self.sounds + } + } + } + + @Test + func `disabled notification skips local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: false, + sound: .hero)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `global notifications toggle disables local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaRestored, + notificationsEnabled: false, + notificationVolume: 0.5, + settings: NotificationDeliverySettings( + enabled: true, + sound: .glass)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `denied authorization skips local delivery`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .denied) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: NotificationDeliverySettings( + enabled: true, + sound: .hero)) + + #expect(recorder.requestCount() == 0) + #expect(recorder.soundsSnapshot().isEmpty) + } + + @Test + func `authorized notification posts request and custom sound`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .sessionQuotaDepleted, + notificationsEnabled: true, + notificationVolume: 0.35, + settings: NotificationDeliverySettings( + enabled: true, + sound: .submarine)) + + #expect(recorder.requestCount() == 1) + #expect(recorder.soundsSnapshot().count == 1) + #expect(recorder.soundsSnapshot().first?.sound == .submarine) + #expect(recorder.soundsSnapshot().first?.volume == 0.35) + } + + @Test + func `system default sound does not use custom sound player`() async { + let recorder = Recorder() + let notifications = Self.makeNotifications(recorder: recorder, authorizationStatus: .authorized) + + await Self.post( + notifications, + event: .providerLogin, + notificationsEnabled: true, + notificationVolume: 0.8, + settings: .localDefault) + + #expect(recorder.requestCount() == 1) + #expect(recorder.soundsSnapshot().isEmpty) + } + + private static func makeNotifications( + recorder: Recorder, + authorizationStatus: UNAuthorizationStatus) + -> AppNotifications + { + AppNotifications( + authorizationStatusProvider: { authorizationStatus }, + authorizationRequester: { authorizationStatus == .authorized || authorizationStatus == .provisional }, + requestPoster: { request in recorder.record(request: request) }, + soundPlayer: { sound, volume in + recorder.record(sound: sound, volume: volume) + return true + }, + allowsPostingWhenRunningUnderTests: true) + } + + private static func post( + _ notifications: AppNotifications, + event: AppNotificationEvent, + notificationsEnabled: Bool, + notificationVolume: Double, + settings: NotificationDeliverySettings) async + { + let task = notifications.post( + idPrefix: "test-\(event.rawValue)", + title: event.rawValue, + body: event.rawValue, + event: event, + notificationsEnabled: notificationsEnabled, + notificationVolume: notificationVolume, + settings: settings) + await task?.value + } +} diff --git a/Tests/CodexBarTests/AugmentStatusProbeTests.swift b/Tests/CodexBarTests/AugmentStatusProbeTests.swift index 83b14904d..c72014ef5 100644 --- a/Tests/CodexBarTests/AugmentStatusProbeTests.swift +++ b/Tests/CodexBarTests/AugmentStatusProbeTests.swift @@ -6,6 +6,23 @@ final class AugmentStatusProbeTests: XCTestCase { try AugmentStatusProbe(baseURL: XCTUnwrap(URL(string: "http://127.0.0.1:1")), timeout: 0.1) } + @MainActor + func test_sessionKeepaliveStartLogsActualIntervals() { + var messages: [String] = [] + let keepalive = AugmentSessionKeepalive { message in + messages.append(message) + } + + keepalive.start() + defer { keepalive.stop() } + + XCTAssertTrue(messages.contains { $0.contains("Check interval: 60s (1 minute)") }) + XCTAssertTrue(messages.contains { $0.contains("Refresh buffer: 300s (5 minutes before expiry)") }) + XCTAssertTrue(messages.contains { $0.contains("Min refresh interval: 60s (1 minute)") }) + XCTAssertFalse(messages.contains { $0.contains("every 5 minutes") }) + XCTAssertFalse(messages.contains { $0.contains("2 minutes") }) + } + func test_debugRawProbe_returnsFormattedOutput() async throws { // Given: A probe instance let probe = try self.failingProbe() @@ -89,6 +106,44 @@ final class AugmentStatusProbeTests: XCTestCase { "Should contain credits information or failure message") } + func test_creditsLimit_prefersUsageUnitsAvailable() throws { + let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data(""" + { + "usageUnitsRemaining": 15, + "usageUnitsConsumedThisBillingCycle": 10, + "usageUnitsAvailable": 100, + "usageBalanceStatus": "active" + } + """.utf8)) + + XCTAssertEqual(response.creditsLimit, 100) + } + + func test_creditsLimit_fallsBackToRemainingPlusConsumedWhenAvailableMissing() throws { + let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data(""" + { + "usageUnitsRemaining": 15, + "usageUnitsConsumedThisBillingCycle": 10, + "usageBalanceStatus": "active" + } + """.utf8)) + + XCTAssertEqual(response.creditsLimit, 25) + } + + func test_creditsLimit_ignoresZeroAvailableValue() throws { + let response = try JSONDecoder().decode(AugmentCreditsResponse.self, from: Data(""" + { + "usageUnitsRemaining": 15, + "usageUnitsConsumedThisBillingCycle": 10, + "usageUnitsAvailable": 0, + "usageBalanceStatus": "active" + } + """.utf8)) + + XCTAssertEqual(response.creditsLimit, 25) + } + // MARK: - Cookie Domain Filtering Tests func test_cookieDomainMatching_exactMatch() throws { diff --git a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift index e928524e9..a84172569 100644 --- a/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift +++ b/Tests/CodexBarTests/BatteryDrainDiagnosticTests.swift @@ -14,11 +14,9 @@ struct BatteryDrainDiagnosticTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test @@ -54,6 +52,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -101,6 +100,7 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == false, @@ -141,9 +141,50 @@ struct BatteryDrainDiagnosticTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect( controller.needsMenuBarIconAnimation() == true, "Should animate when enabled provider has no data") } + + @Test + func `Enabled provider with error should not animate`() { + self.ensureAppKitInitialized() + + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "BatteryDrain-ErrorStops"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let registry = ProviderRegistry.shared + if let meta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: meta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setErrorForTesting("simulated Codex RPC timeout", provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(store.isStale(provider: .codex) == true) + #expect( + controller.needsMenuBarIconAnimation() == false, + "Should not animate when provider has recorded an error") + #expect(controller.animationDriver == nil) + } } diff --git a/Tests/CodexBarTests/BedrockMenuCardTests.swift b/Tests/CodexBarTests/BedrockMenuCardTests.swift new file mode 100644 index 000000000..e3a4e8a5a --- /dev/null +++ b/Tests/CodexBarTests/BedrockMenuCardTests.swift @@ -0,0 +1,51 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct BedrockMenuCardTests { + @Test + func `bedrock cost section labels latest billing day`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.bedrock]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: 12.34, + last30DaysTokens: nil, + last30DaysCostUSD: 56.78, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-12", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: 12.34, + modelsUsed: ["Amazon Bedrock"], + modelBreakdowns: nil), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .bedrock, + metadata: metadata, + snapshot: nil, + 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.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.") + } +} diff --git a/Tests/CodexBarTests/BedrockSettingsFlowTests.swift b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift new file mode 100644 index 000000000..f14a3ca32 --- /dev/null +++ b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct BedrockSettingsFlowTests { + @Test + func `settings store maps Bedrock credentials into provider environment`() throws { + let suite = "BedrockSettingsFlowTests-settings-\(UUID().uuidString)" + 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.bedrockAccessKeyID = "AKIATEST" + settings.bedrockSecretAccessKey = "secret" + settings.bedrockRegion = "us-west-2" + + let config = try #require(settings.providerConfig(for: .bedrock)) + #expect(config.sanitizedAPIKey == "AKIATEST") + #expect(config.sanitizedSecretKey == "secret") + #expect(config.sanitizedCookieHeader == nil) + #expect(config.sanitizedRegion == "us-west-2") + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .bedrock, + settings: settings, + tokenOverride: nil) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "us-west-2") + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + #expect(BedrockProviderImplementation().isAvailable(context: ProviderAvailabilityContext( + provider: .bedrock, + settings: settings, + environment: env))) + } + + @Test + func `bedrock availability requires secret access key`() throws { + let suite = "BedrockSettingsFlowTests-missing-secret-\(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.bedrockAccessKeyID = "AKIATEST" + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .bedrock, + settings: settings, + tokenOverride: nil) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + #expect(!BedrockProviderImplementation().isAvailable(context: ProviderAvailabilityContext( + provider: .bedrock, + settings: settings, + environment: env))) + } +} diff --git a/Tests/CodexBarTests/BedrockUsageStatsTests.swift b/Tests/CodexBarTests/BedrockUsageStatsTests.swift new file mode 100644 index 000000000..44e15913c --- /dev/null +++ b/Tests/CodexBarTests/BedrockUsageStatsTests.swift @@ -0,0 +1,340 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockUsageStatsTests { + @Test + func `to usage snapshot with budget shows primary window`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 50, + monthlyBudget: 200, + inputTokens: 1_500_000, + outputTokens: 500_000, + region: "us-east-1", + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "Monthly budget") + #expect(usage.primary?.resetsAt != nil) + #expect(usage.providerCost?.used == 50) + #expect(usage.providerCost?.limit == 200) + #expect(usage.providerCost?.currencyCode == "USD") + #expect(usage.providerCost?.period == "Monthly") + #expect(usage.identity?.providerID == .bedrock) + #expect(usage.identity?.loginMethod?.contains("Spend: $50.00") == true) + } + + @Test + func `to usage snapshot without budget omits primary window`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 75.5, + monthlyBudget: nil, + region: "us-west-2", + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 75.5) + #expect(usage.providerCost?.limit == 0) + } + + @Test + func `settings reader parses credentials from environment`() { + let env = [ + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "secret", + "AWS_REGION": "eu-west-1", + "CODEXBAR_BEDROCK_BUDGET": "500", + ] + + #expect(BedrockSettingsReader.accessKeyID(environment: env) == "AKIAIOSFODNN7EXAMPLE") + #expect(BedrockSettingsReader.secretAccessKey(environment: env) == "secret") + #expect(BedrockSettingsReader.region(environment: env) == "eu-west-1") + #expect(BedrockSettingsReader.budget(environment: env) == 500) + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + } + + @Test + func `settings reader requires both credential fields`() { + #expect(!BedrockSettingsReader.hasCredentials(environment: [:])) + #expect(!BedrockSettingsReader.hasCredentials(environment: [ + "AWS_ACCESS_KEY_ID": "AKIATEST", + ])) + #expect(!BedrockSettingsReader.hasCredentials(environment: [ + "AWS_SECRET_ACCESS_KEY": "secret", + ])) + } + + @Test + func `cost explorer response parsing extracts total`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = """ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"}, + "Groups": [ + { + "Keys": ["Claude Opus (Bedrock Edition)"], + "Metrics": {"UnblendedCost": {"Amount": "30.00", "Unit": "USD"}} + }, + { + "Keys": ["Claude Sonnet (Bedrock Edition)"], + "Metrics": {"UnblendedCost": {"Amount": "12.50", "Unit": "USD"}} + }, + { + "Keys": ["Amazon EC2"], + "Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}} + } + ] + } + ] + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: 100, + environment: ["CODEXBAR_BEDROCK_API_URL": "https://bedrock.test"]) + + #expect(usage.monthlySpend == 42.50) + #expect(usage.monthlyBudget == 100) + #expect(usage.region == "us-east-1") + } + + @Test + func `cost explorer pagination aggregates monthly total`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + let responses = BedrockStubResponseQueue([ + """ + { + "NextPageToken": "page-2", + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"}, + "Groups": [ + { + "Keys": ["Amazon EC2"], + "Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}} + } + ] + } + ] + } + """, + """ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"}, + "Groups": [ + { + "Keys": ["Amazon Bedrock"], + "Metrics": {"UnblendedCost": {"Amount": "12.00", "Unit": "USD"}} + }, + { + "Keys": ["Claude Sonnet (Bedrock Edition)"], + "Metrics": {"UnblendedCost": {"Amount": "8.00", "Unit": "USD"}} + } + ] + } + ] + } + """, + ]) + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return responses.next(url: url) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: nil, + environment: [BedrockSettingsReader.apiURLKey: "https://bedrock.test"]) + + #expect(usage.monthlySpend == 20) + #expect(responses.remainingCount == 0) + } + + @Test + func `cost usage fetcher uses provided bedrock environment`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + let responses = BedrockStubResponseQueue([ + """ + { + "NextPageToken": "daily-page-2", + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2025-12-10", "End": "2025-12-11"}, + "Groups": [ + { + "Keys": ["Amazon EC2"], + "Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}} + } + ] + } + ] + } + """, + """ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2025-12-10", "End": "2025-12-11"}, + "Groups": [ + { + "Keys": ["Amazon Bedrock"], + "Metrics": {"UnblendedCost": {"Amount": "7.25", "Unit": "USD"}} + } + ] + } + ] + } + """, + ]) + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return responses.next(url: url) + } + + let snapshot = try await CostUsageFetcher().loadTokenSnapshot( + provider: .bedrock, + environment: [ + BedrockSettingsReader.accessKeyIDKey: "AKIATEST", + BedrockSettingsReader.secretAccessKeyKey: "testSecret", + BedrockSettingsReader.apiURLKey: "https://bedrock.test", + ], + now: Date(timeIntervalSince1970: 1_765_324_800)) + + #expect(snapshot.last30DaysCostUSD == 7.25) + #expect(snapshot.sessionCostUSD == 7.25) + #expect(snapshot.daily.map(\.date) == ["2025-12-10"]) + #expect(responses.remainingCount == 0) + } + + @Test + func `current month range uses UTC calendar`() throws { + let originalTimeZone = NSTimeZone.default + NSTimeZone.default = TimeZone(secondsFromGMT: 14 * 60 * 60)! + defer { + NSTimeZone.default = originalTimeZone + } + + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-10T12:00:00Z")) + let range = BedrockUsageFetcher.currentMonthRange(now: now) + + #expect(range.start == "2026-05-01") + #expect(range.end == "2026-05-11") + } + + private final class BedrockStubResponseQueue { + private let lock = NSLock() + private var bodies: [String] + + init(_ bodies: [String]) { + self.bodies = bodies + } + + var remainingCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.bodies.count + } + + func next(url: URL) -> (HTTPURLResponse, Data) { + self.lock.lock() + let body = self.bodies.isEmpty ? #"{"ResultsByTime":[]}"# : self.bodies.removeFirst() + self.lock.unlock() + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class BedrockStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "bedrock.test" + } + + 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/BrowserCookieOrderLabelTests.swift b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift index e21c6233b..ac46fe826 100644 --- a/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift +++ b/Tests/CodexBarTests/BrowserCookieOrderLabelTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import SweetCookieKit import Testing +@testable import CodexBarCore struct BrowserCookieOrderStatusStringTests { #if os(macOS) @@ -10,6 +10,12 @@ struct BrowserCookieOrderStatusStringTests { #expect(Array(order.prefix(3)) == [.safari, .chrome, .firefox]) } + @Test + func `automatic cookie import includes newly supported chromium browsers`() { + #expect(Browser.defaultImportOrder.contains(.comet)) + #expect(Browser.defaultImportOrder.contains(.yandex)) + } + @Test func `cursor no session includes browser login hint`() { let order = ProviderDefaults.metadata[.cursor]?.browserCookieOrder ?? Browser.defaultImportOrder @@ -23,5 +29,18 @@ struct BrowserCookieOrderStatusStringTests { let message = FactoryStatusProbeError.noSessionCookie.errorDescription ?? "" #expect(message.contains(order.loginHint)) } + + @Test + func `opencode go automatic cookies use full provider browser order`() { + let order = OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencodego) + #expect(order == ProviderDefaults.metadata[.opencodego]?.browserCookieOrder) + #expect(order.contains(.edge)) + #expect(order.contains(.firefox)) + } + + @Test + func `opencode automatic cookies keep chrome only default`() { + #expect(OpenCodeWebCookieSupport.automaticImportOrder(provider: .opencode) == [.chrome]) + } #endif } diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 94cc9c36e..4ce346e23 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -95,6 +95,37 @@ struct BrowserDetectionTests { #expect(browsers.cookieImportCandidates(using: detection) == [.safari]) } + @Test + func `keychain interaction suppresses chromium cookie source during cooldown`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1000) + var preflightCount = 0 + + KeychainAccessGate.withTaskOverrideForTesting(false) { + 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 `dia requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/CodexBarTests/CLICacheTests.swift b/Tests/CodexBarTests/CLICacheTests.swift new file mode 100644 index 000000000..21ca7d869 --- /dev/null +++ b/Tests/CodexBarTests/CLICacheTests.swift @@ -0,0 +1,32 @@ +import Commander +import Testing +@testable import CodexBarCLI + +struct CLICacheTests { + @Test + func `cache clear parses cookies provider flags`() throws { + let parser = CommandParser(signature: CodexBarCLI._cacheSignatureForTesting()) + let parsed = try parser.parse(arguments: ["--cookies", "--provider", "claude", "--json"]) + + #expect(parsed.flags.contains("cookies")) + #expect(parsed.flags.contains("jsonShortcut")) + #expect(parsed.options["provider"] == ["claude"]) + #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) + } + + @Test + func `provider scope is rejected for cost clearing`() { + #expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: nil, clearCost: true) == nil) + #expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: "claude", clearCost: false) == nil) + #expect(CodexBarCLI.cacheClearProviderScopeError(rawProvider: "claude", clearCost: true)? + .contains("--provider only scopes cookie caches") == true) + } + + @Test + func `cache help documents provider as cookie scoped`() { + let help = CodexBarCLI.cacheHelp(version: "0.0.0") + + #expect(help.contains("--provider with --cookies")) + #expect(help.contains("codexbar cache clear --cookies --provider claude")) + } +} diff --git a/Tests/CodexBarTests/CLIConfigCommandTests.swift b/Tests/CodexBarTests/CLIConfigCommandTests.swift new file mode 100644 index 000000000..3c81073c0 --- /dev/null +++ b/Tests/CodexBarTests/CLIConfigCommandTests.swift @@ -0,0 +1,119 @@ +import CodexBarCore +import Commander +import Testing +@testable import CodexBarCLI + +struct CLIConfigCommandTests { + @Test + func `config set api key parses provider stdin and no enable flags`() throws { + let parser = CommandParser(signature: CodexBarCLI._configSetAPIKeySignatureForTesting()) + let parsed = try parser.parse(arguments: [ + "--provider", "elevenlabs", + "--stdin", + "--no-enable", + "--json", + ]) + + #expect(parsed.options["provider"] == ["elevenlabs"]) + #expect(parsed.flags.contains("stdin")) + #expect(parsed.flags.contains("noEnable")) + #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) + } + + @Test + func `config set api key stores key and enables provider`() { + let config = CodexBarConfig.makeDefault() + let updated = CodexBarCLI.configSettingAPIKey( + config, + provider: .elevenlabs, + apiKey: "xi-test-token", + enableProvider: true) + let provider = updated.providerConfig(for: .elevenlabs) + + #expect(provider?.sanitizedAPIKey == "xi-test-token") + #expect(provider?.enabled == true) + } + + @Test + func `config provider toggle parses provider and json flags`() throws { + let parser = CommandParser(signature: CodexBarCLI._configProviderToggleSignatureForTesting()) + let parsed = try parser.parse(arguments: [ + "--provider", "grok", + "--json", + "--pretty", + ]) + + #expect(parsed.options["provider"] == ["grok"]) + #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) + #expect(parsed.flags.contains("pretty")) + } + + @Test + func `config provider toggle enables and disables provider`() { + let config = CodexBarConfig.makeDefault() + let enabled = CodexBarCLI.configSettingProviderEnabled(config, provider: .grok, enabled: true) + let disabled = CodexBarCLI.configSettingProviderEnabled(enabled, provider: .grok, enabled: false) + + #expect(enabled.providerConfig(for: .grok)?.enabled == true) + #expect(disabled.providerConfig(for: .grok)?.enabled == false) + } + + @Test + func `config provider status includes effective default`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .grok, enabled: true), + ProviderConfig(id: .cursor, enabled: false), + ]) + let statuses = CodexBarCLI.configProviderStatuses(config) + let grok = try #require(statuses.first { $0.provider == "grok" }) + let cursor = try #require(statuses.first { $0.provider == "cursor" }) + + #expect(grok.enabled) + #expect(!cursor.enabled) + #expect(statuses.count == UsageProvider.allCases.count) + } + + @Test + func `config set api key only accepts consumed config keys`() { + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .elevenlabs)) + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .openai)) + #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .bedrock)) + #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .deepseek)) + #expect(!ProviderConfigEnvironment.supportsAPIKeyOverride(for: .cursor)) + } + + @Test + func `config set api key preserves disabled provider when requested`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .elevenlabs, enabled: false)) + + let updated = CodexBarCLI.configSettingAPIKey( + config, + provider: .elevenlabs, + apiKey: "xi-test-token", + enableProvider: false) + let provider = updated.providerConfig(for: .elevenlabs) + + #expect(provider?.sanitizedAPIKey == "xi-test-token") + #expect(provider?.enabled == false) + } + + @Test + func `config set api key rejects ambiguous input`() { + #expect(throws: CLIArgumentError.self) { + try CodexBarCLI.resolveConfigAPIKeyInput(apiKey: "xi-test-token", readFromStdin: true) + } + } + + @Test + func `config help documents set api key`() { + let help = CodexBarCLI.configHelp(version: "0.0.0") + + #expect(help.contains("config set-api-key --provider ")) + #expect(help.contains("config providers")) + #expect(help.contains("config enable --provider ")) + #expect(help.contains("config disable --provider ")) + #expect(help.contains("--stdin")) + #expect(help.contains("enables that provider by default")) + } +} diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 1f315a8ea..9acef3904 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -30,9 +30,11 @@ struct CLICostTests { .replacingOccurrences(of: "\u{00A0}", with: " ") .replacingOccurrences(of: "$ ", with: "$") - #expect(output.contains("Claude Cost (local)")) + #expect(output.contains("Claude Cost (API-rate estimate)")) #expect(output.contains("Today: $1.25 · 1.2K tokens")) #expect(output.contains("Last 30 days: $9.99 · 9K tokens")) + #expect(output.contains("cache read/write tokens")) + #expect(output.contains("Claude Code /status")) } @Test @@ -138,4 +140,12 @@ struct CLICostTests { #expect(json.contains("\"cost\":0")) #expect(json.contains("\"totalTokens\":140")) } + + @Test + func `cost estimate hint is stable string`() { + let hint = UsageFormatter.costEstimateHint + #expect(!hint.isEmpty) + #expect(hint.contains("Estimated")) + #expect(UsageFormatter.costEstimateHint(provider: .claude).contains("cache read/write tokens")) + } } diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 0aa92f786..384ea6686 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -1,51 +1,45 @@ import CodexBarCore import Commander import Foundation -import Testing +import XCTest @testable import CodexBarCLI -struct CLIEntryTests { - @Test - func `effective argv defaults to usage`() { - #expect(CodexBarCLI.effectiveArgv([]) == ["usage"]) - #expect(CodexBarCLI.effectiveArgv(["--json"]) == ["usage", "--json"]) - #expect(CodexBarCLI.effectiveArgv(["usage", "--json"]) == ["usage", "--json"]) +final class CLIEntryTests: XCTestCase { + func test_effectiveArgvDefaultsToUsage() { + XCTAssertEqual(CodexBarCLI.effectiveArgv([]), ["usage"]) + XCTAssertEqual(CodexBarCLI.effectiveArgv(["--json"]), ["usage", "--json"]) + XCTAssertEqual(CodexBarCLI.effectiveArgv(["usage", "--json"]), ["usage", "--json"]) } - @Test - func `decodes format from options and flags`() { + func test_decodesFormatFromOptionsAndFlags() { let jsonOption = ParsedValues(positional: [], options: ["format": ["json"]], flags: []) - #expect(CodexBarCLI._decodeFormatForTesting(from: jsonOption) == .json) + XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: jsonOption), .json) let jsonFlag = ParsedValues(positional: [], options: [:], flags: ["json"]) - #expect(CodexBarCLI._decodeFormatForTesting(from: jsonFlag) == .json) + XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: jsonFlag), .json) let textDefault = ParsedValues(positional: [], options: [:], flags: []) - #expect(CodexBarCLI._decodeFormatForTesting(from: textDefault) == .text) + XCTAssertEqual(CodexBarCLI._decodeFormatForTesting(from: textDefault), .text) } - @Test - func `provider selection prefers override`() { + func test_providerSelectionPrefersOverride() { let selection = CodexBarCLI.providerSelection(rawOverride: "codex", enabled: [.claude, .gemini]) - #expect(selection.asList == [.codex]) + XCTAssertEqual(selection.asList, [.codex]) } - @Test - func `normalize version extracts numeric`() { - #expect(CodexBarCLI.normalizeVersion(raw: "codex 1.2.3 (build 4)") == "1.2.3") - #expect(CodexBarCLI.normalizeVersion(raw: " v2.0 ") == "2.0") + func test_normalizeVersionExtractsNumeric() { + XCTAssertEqual(CodexBarCLI.normalizeVersion(raw: "codex 1.2.3 (build 4)"), "1.2.3") + XCTAssertEqual(CodexBarCLI.normalizeVersion(raw: " v2.0 "), "2.0") } - @Test - func `make header includes version when available`() { + func test_makeHeaderIncludesVersionWhenAvailable() { let header = CodexBarCLI.makeHeader(provider: .codex, version: "1.2.3", source: "cli") - #expect(header.contains("Codex")) - #expect(header.contains("1.2.3")) - #expect(header.contains("cli")) + XCTAssertTrue(header.contains("Codex")) + XCTAssertTrue(header.contains("1.2.3")) + XCTAssertTrue(header.contains("cli")) } - @Test - func `CLI version falls back to containing app bundle`() throws { + func test_cliVersionFallsBackToContainingAppBundle() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cli-version-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -63,23 +57,20 @@ struct CLIEntryTests { let helperURL = helpersURL.appendingPathComponent("CodexBarCLI") try Data().write(to: helperURL) - #expect(CodexBarCLI.containingAppVersion(for: helperURL) == "9.8.7") + XCTAssertEqual(CodexBarCLI.containingAppVersion(for: helperURL), "9.8.7") } - @Test - func `CLI version follows symlinked helper`() throws { + func test_cliVersionFollowsSymlinkedHelper() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-cli-version-symlink-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) - let emptyBundleURL = root.appendingPathComponent("Empty.bundle", isDirectory: true) let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true) let binURL = root.appendingPathComponent("bin", isDirectory: true) try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: emptyBundleURL, withIntermediateDirectories: true) let infoURL = contentsURL.appendingPathComponent("Info.plist") let plist: [String: Any] = ["CFBundleShortVersionString": "2.4.6"] @@ -92,12 +83,54 @@ struct CLIEntryTests { let symlinkURL = binURL.appendingPathComponent("codexbar") try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL) - let emptyBundle = try #require(Bundle(url: emptyBundleURL)) - #expect(CodexBarCLI.currentVersion(bundle: emptyBundle, executablePath: symlinkURL.path) == "2.4.6") + XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: symlinkURL.path), "2.4.6") } - @Test - func `render open AI web dashboard text includes summary`() { + func test_cliVersionFallsBackToAdjacentVersionFile() throws { + try self.expectAdjacentVersionFile(raw: "v3.2.1\n", expected: "3.2.1") + try self.expectAdjacentVersionFile(raw: "3.2.2\n", expected: "3.2.2") + try self.expectAdjacentVersionFile(raw: "version-3.2.3\n", expected: "version-3.2.3") + } + + func test_cliVersionPrefersAdjacentVersionOverStandaloneBundleName() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-bundle-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let binURL = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + + let helperURL = binURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + try "4.5.6\n".write( + to: binURL.appendingPathComponent("VERSION"), + atomically: false, + encoding: .utf8) + + XCTAssertEqual( + CodexBarCLI.currentVersion(bundleVersion: "CodexBar", executablePath: helperURL.path), + "4.5.6") + } + + private func expectAdjacentVersionFile(raw: String, expected: String) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cli-version-file-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let binURL = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true) + + let helperURL = binURL.appendingPathComponent("CodexBarCLI") + try Data().write(to: helperURL) + try raw.write( + to: binURL.appendingPathComponent("VERSION"), + atomically: false, + encoding: .utf8) + + XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: helperURL.path), expected) + } + + func test_renderOpenAIWebDashboardTextIncludesSummary() { let event = CreditEvent( date: Date(timeIntervalSince1970: 1_700_000_000), service: "codex", @@ -118,87 +151,79 @@ struct CLIEntryTests { let text = CodexBarCLI.renderOpenAIWebDashboardText(snapshot) - #expect(text.contains("Web session: user@example.com")) - #expect(text.contains("Code review: 45% remaining (Resets in ")) - #expect(text.contains("Web history: 1 events")) + XCTAssertTrue(text.contains("Web session: user@example.com")) + XCTAssertTrue(text.contains("Code review: 45% remaining (Resets in ")) + XCTAssertTrue(text.contains("Web history: 1 events")) } - @Test - func `maps errors to exit codes`() { - #expect(CodexBarCLI.mapError(CodexStatusProbeError.codexNotInstalled) == ExitCode(2)) - #expect(CodexBarCLI.mapError(CodexStatusProbeError.timedOut) == ExitCode(4)) - #expect(CodexBarCLI.mapError(UsageError.noRateLimitsFound) == ExitCode(3)) + func test_mapsErrorsToExitCodes() { + XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.codexNotInstalled), ExitCode(2)) + XCTAssertEqual(CodexBarCLI.mapError(CodexStatusProbeError.timedOut), ExitCode(4)) + XCTAssertEqual(CodexBarCLI.mapError(UsageError.noRateLimitsFound), ExitCode(3)) } - @Test - func `provider selection falls back to both for primary pair`() { + func test_providerSelectionFallsBackToBothForPrimaryPair() { let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .claude]) switch selection { case .both: break default: - #expect(Bool(false)) + XCTFail("Expected both selection") } } - @Test - func `provider selection falls back to custom when non primary`() { + func test_providerSelectionFallsBackToCustomWhenNonPrimary() { let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: [.codex, .gemini]) switch selection { case let .custom(providers): - #expect(providers == [.codex, .gemini]) + XCTAssertEqual(providers, [.codex, .gemini]) default: - #expect(Bool(false)) + XCTFail("Expected custom selection") } } - @Test - func `provider selection defaults to codex when empty`() { + func test_providerSelectionHonorsEmptyEnabledSet() { let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: []) switch selection { - case let .single(provider): - #expect(provider == .codex) + case let .custom(providers): + XCTAssertEqual(providers, []) default: - #expect(Bool(false)) + XCTFail("Expected empty custom selection") } } - @Test - func `decodes source and timeout options`() throws { + func test_decodesSourceAndTimeoutOptions() throws { let signature = CodexBarCLI._usageSignatureForTesting() let parser = CommandParser(signature: signature) let parsed = try parser.parse(arguments: ["--web-timeout", "45", "--source", "oauth"]) - #expect(CodexBarCLI._decodeWebTimeoutForTesting(from: parsed) == 45) - #expect(CodexBarCLI._decodeSourceModeForTesting(from: parsed) == .oauth) + XCTAssertEqual(CodexBarCLI._decodeWebTimeoutForTesting(from: parsed), 45) + XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsed), .oauth) let parsedWeb = try parser.parse(arguments: ["--web"]) - #expect(CodexBarCLI._decodeSourceModeForTesting(from: parsedWeb) == .web) + XCTAssertEqual(CodexBarCLI._decodeSourceModeForTesting(from: parsedWeb), .web) } - @Test - func `should use color respects format and flags`() { - #expect(!CodexBarCLI.shouldUseColor(noColor: true, format: .text)) - #expect(!CodexBarCLI.shouldUseColor(noColor: false, format: .json)) + func test_shouldUseColorRespectsFormatAndFlags() { + XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: true, format: .text)) + XCTAssertFalse(CodexBarCLI.shouldUseColor(noColor: false, format: .json)) } - @Test - func `kilo usage text notes show fallback only for auto resolved to CLI`() { - #expect(CodexBarCLI.usageTextNotes( + func test_kiloUsageTextNotesShowFallbackOnlyForAutoResolvedToCLI() { + XCTAssertEqual(CodexBarCLI.usageTextNotes( provider: .kilo, sourceMode: .auto, - resolvedSourceLabel: "cli") == ["Using CLI fallback"]) - #expect(CodexBarCLI.usageTextNotes( + resolvedSourceLabel: "cli"), ["Using CLI fallback"]) + XCTAssertTrue(CodexBarCLI.usageTextNotes( provider: .kilo, sourceMode: .api, resolvedSourceLabel: "cli").isEmpty) - #expect(CodexBarCLI.usageTextNotes( + XCTAssertTrue(CodexBarCLI.usageTextNotes( provider: .codex, sourceMode: .auto, resolvedSourceLabel: "cli").isEmpty) } - @Test - func `kilo auto fallback summary includes ordered attempt details`() { + func test_kiloAutoFallbackSummaryIncludesOrderedAttemptDetails() { let attempts = [ ProviderFetchAttempt( strategyID: "kilo.api", @@ -221,13 +246,10 @@ struct CLIEntryTests { " -> cli: Kilo CLI session not found.", ].joined() - #expect( - summary == - expected) + XCTAssertEqual(summary, expected) } - @Test - func `kilo auto fallback summary is nil outside kilo auto failures`() { + func test_kiloAutoFallbackSummaryIsNilOutsideKiloAutoFailures() { let attempts = [ ProviderFetchAttempt( strategyID: "kilo.api", @@ -236,21 +258,22 @@ struct CLIEntryTests { errorDescription: "example"), ] - #expect(CodexBarCLI.kiloAutoFallbackSummary( + XCTAssertNil(CodexBarCLI.kiloAutoFallbackSummary( provider: .kilo, sourceMode: .api, - attempts: attempts) == nil) - #expect(CodexBarCLI.kiloAutoFallbackSummary( + attempts: attempts)) + XCTAssertNil(CodexBarCLI.kiloAutoFallbackSummary( provider: .codex, sourceMode: .auto, - attempts: attempts) == nil) + attempts: attempts)) } - @Test - func `source mode requires web support is provider aware`() { - #expect(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .kilo)) - #expect(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .codex)) - #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .kilo)) - #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.api, provider: .kilo)) + func test_sourceModeRequiresWebSupportIsProviderAware() { + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .kilo)) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .codex)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .kilo)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .grok)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .grok)) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(.api, provider: .kilo)) } } diff --git a/Tests/CodexBarTests/CLIOutputTests.swift b/Tests/CodexBarTests/CLIOutputTests.swift index 17de334a6..caa5c86d3 100644 --- a/Tests/CodexBarTests/CLIOutputTests.swift +++ b/Tests/CodexBarTests/CLIOutputTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import CodexBarCLI +@testable import CodexBarCore struct CLIOutputTests { @Test @@ -26,4 +27,41 @@ struct CLIOutputTests { let error = first?["error"] as? [String: Any] #expect(error?["message"] as? String == "Nope") } + + @Test + func `exit omits generic error when command already emitted payload`() { + #expect(!CodexBarCLI.shouldPrintExitError(code: .success, message: nil)) + #expect(!CodexBarCLI.shouldPrintExitError(code: .failure, message: nil)) + #expect(CodexBarCLI.shouldPrintExitError(code: .failure, message: "Nope")) + } + + @Test + func `text renderer includes deepgram usage metrics`() { + let deepgram = DeepgramUsageSnapshot( + projectID: "project-123", + start: "2026-05-10", + end: "2026-05-17", + hours: 12.5, + totalHours: 14, + agentHours: 1.25, + tokensIn: 100, + tokensOut: 50, + ttsCharacters: 1200, + requests: 42, + updatedAt: Date(timeIntervalSince1970: 0)) + let text = CLIRenderer.renderText( + provider: .deepgram, + snapshot: deepgram.toUsageSnapshot(), + credits: nil, + context: RenderContext( + header: "Deepgram (api)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Requests: 42")) + #expect(text.contains("Usage: 12.5 audio hours · 14 billable hours")) + #expect(text.contains("Usage: 1.2 agent hours · 150 tokens · 1,200 TTS chars")) + #expect(text.contains("Period: 2026-05-10 to 2026-05-17")) + } } diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index 2db996bd6..8fa24d0a7 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -70,11 +70,19 @@ struct CLIProviderSelectionTests { } @Test - func `provider selection uses all when enabled`() { + func `provider selection uses enabled providers when three or more are enabled`() { let selection = CodexBarCLI.providerSelection( rawOverride: nil, enabled: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) - #expect(selection.asList == ProviderSelection.all.asList) + #expect(selection.asList == [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) + } + + @Test + func `provider selection does not expand three enabled providers to all providers`() { + let enabled: [UsageProvider] = [.codex, .claude, .copilot] + let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: enabled) + #expect(selection.asList == enabled) + #expect(!selection.asList.contains(.gemini)) } @Test @@ -97,8 +105,8 @@ struct CLIProviderSelectionTests { } @Test - func `provider selection defaults to codex when empty`() { + func `provider selection honors empty enabled set`() { let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: []) - #expect(selection.asList == [.codex]) + #expect(selection.asList == []) } } diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift new file mode 100644 index 000000000..84149ed5e --- /dev/null +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -0,0 +1,140 @@ +import Commander +import Foundation +import Testing +@testable import CodexBarCLI + +struct CLIServeRouterTests { + @Test + func `local http parser accepts only loopback host headers`() throws { + let allowedHosts = [ + "localhost", + "localhost.", + "localhost:8080", + "127.0.0.1", + "127.0.0.1:8080", + "[::1]", + "[::1]:8080", + ] + + for host in allowedHosts { + let request = try Self.parsedRequest(host: host) + #expect(request.host == host) + #expect(request.path == "/usage") + } + } + + @Test + func `local http parser rejects hostile missing and duplicate hosts`() { + Self.expectParseFailure(raw: "GET /usage HTTP/1.1\r\n\r\n", .missingHost) + Self.expectParseFailure(raw: "GET /usage HTTP/1.1\r\nHost: evil.test\r\n\r\n", .disallowedHost) + Self.expectParseFailure(raw: "GET /usage HTTP/1.1\r\nHost: localhost, evil.test\r\n\r\n", .disallowedHost) + Self.expectParseFailure(raw: "GET /usage HTTP/1.1\r\nHost: localhost:abc\r\n\r\n", .disallowedHost) + Self.expectParseFailure( + raw: "GET /usage HTTP/1.1\r\nHost: localhost\r\nHost: 127.0.0.1\r\n\r\n", + .duplicateHost) + } + + @Test + func `routes health usage and cost endpoints`() throws { + #expect(try CLIServeRouter.route(method: "GET", path: "/health", queryItems: [:]) == .health) + #expect(try CLIServeRouter.route(method: "GET", path: "/usage", queryItems: [:]) == .usage(provider: nil)) + #expect( + try CLIServeRouter.route( + method: "GET", + path: "/usage", + queryItems: ["provider": "claude"]) == .usage(provider: "claude")) + #expect( + try CLIServeRouter.route( + method: "GET", + path: "/cost", + queryItems: ["provider": "codex"]) == .cost(provider: "codex")) + } + + @Test + func `rejects non get methods`() { + do { + _ = try CLIServeRouter.route(method: "POST", path: "/usage", queryItems: [:]) + Issue.record("Expected methodNotAllowed") + } catch let error as CLIServeRouteError { + #expect(error == .methodNotAllowed) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `rejects unknown paths`() { + do { + _ = try CLIServeRouter.route(method: "GET", path: "/missing", queryItems: [:]) + Issue.record("Expected notFound") + } catch let error as CLIServeRouteError { + #expect(error == .notFound) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `serve numeric options reject malformed values`() { + #expect(CodexBarCLI.decodeServePort(from: ParsedValues( + positional: [], + options: ["port": ["abc"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServePort(from: ParsedValues( + positional: [], + options: ["port": ["0"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServePort(from: ParsedValues( + positional: [], + options: ["port": ["65536"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServePort(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == 8080) + + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["later"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: ["refreshInterval": ["-1"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRefreshInterval(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == 60) + } + + @Test + func `serve cache skips provider error payloads`() { + let success = CLILocalHTTPResponse( + status: .ok, + body: Data(#"[{"provider":"codex","source":"local"}]"#.utf8)) + let providerError = CLILocalHTTPResponse( + status: .ok, + body: Data(#"[{"provider":"codex","source":"local","error":{"message":"temporary"}}]"#.utf8)) + let routeError = CLILocalHTTPResponse( + status: .badRequest, + body: Data(#"{"error":"bad request"}"#.utf8)) + + #expect(CodexBarCLI.shouldCacheServeResponse(success)) + #expect(!CodexBarCLI.shouldCacheServeResponse(providerError)) + #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) + } + + 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() + } + + private static func expectParseFailure(raw: String, _ expected: CLILocalHTTPRequestParseError) { + switch CLILocalHTTPRequest.parse(Data(raw.utf8)) { + case .success: + Issue.record("Expected \(expected)") + case let .failure(error): + #expect(error == expected) + } + } +} diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 700fb9257..7df099b3f 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -4,6 +4,55 @@ import Testing @testable import CodexBarCLI struct CLISnapshotTests { + @Test + func `renders Factory token rate billing with time window labels`() { + let snap = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: .init(usedPercent: 25, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: .init(usedPercent: 50, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CLIRenderer.renderText( + provider: .factory, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Droid (factory)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("5-hour: 88% left")) + #expect(output.contains("Weekly: 75% left")) + #expect(output.contains("Monthly: 50% left")) + #expect(!output.contains("Standard:")) + #expect(!output.contains("Premium:")) + } + + @Test + func `renders Factory legacy billing with pool labels`() { + let snap = UsageSnapshot( + primary: .init(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: .init(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CLIRenderer.renderText( + provider: .factory, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Droid (factory)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("Standard: 88% left")) + #expect(output.contains("Premium: 75% left")) + #expect(!output.contains("5-hour:")) + #expect(!output.contains("Monthly:")) + } + @Test func `renders text snapshot for codex`() { let identity = ProviderIdentitySnapshot( @@ -39,11 +88,11 @@ struct CLISnapshotTests { #expect(output.contains("Weekly: 75% left")) #expect(output.contains("Credits: 42")) #expect(output.contains("Account: user@example.com")) - #expect(output.contains("Plan: Pro")) + #expect(output.contains("Plan: Pro 20x")) } @Test - func `renders Codex prolite plan with spaced display name`() { + func `renders Codex prolite plan with multiplier display name`() { let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: "user@example.com", @@ -66,10 +115,42 @@ struct CLISnapshotTests { useColor: false, resetStyle: .absolute)) - #expect(output.contains("Plan: Pro Lite")) + #expect(output.contains("Plan: Pro 5x")) + #expect(!output.contains("Plan: Pro Lite")) #expect(!output.contains("Plan: Prolite")) } + @Test + func `renders Codex plan only limits as unavailable`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snap = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 1.2.3 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("Limits: not available")) + #expect(output.contains("Account: user@example.com")) + #expect(output.contains("Plan: Pro 20x")) + #expect(!output.contains("Session:")) + #expect(!output.contains("Weekly:")) + } + @Test func `renders text snapshot for claude without weekly`() { let snap = UsageSnapshot( @@ -156,6 +237,31 @@ struct CLISnapshotTests { #expect(!output.contains("Resets 10/100 credits")) } + @Test + func `renders crof dollar balance as detail not reset`() { + let meta = ProviderDescriptorRegistry.descriptor(for: .crof).metadata + let snap = CrofUsageSnapshot( + credits: 9.9999, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: Date(timeIntervalSince1970: 0)).toUsageSnapshot() + + let output = CLIRenderer.renderText( + provider: .crof, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Crof", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("\(meta.sessionLabel): 99% left")) + #expect(output.contains("\(meta.weeklyLabel): 100% left")) + #expect(output.contains("$9.99")) + #expect(!output.contains("Resets $9.99")) + } + @Test func `renders kilo plan activity and fallback note`() { let now = Date(timeIntervalSince1970: 0) diff --git a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift new file mode 100644 index 000000000..6a63827e9 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeAdminAPIUsageTests { + private func makeContext( + apiKey: String = "sk-ant-admin-test", + sourceMode: ProviderSourceMode = .api) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let env = [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: apiKey] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `prefers primary Anthropic admin key environment variable`() { + let token = ClaudeAdminAPISettingsReader.apiKey(environment: [ + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-alt", + ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-ant-admin-primary", + ]) + + #expect(token == "sk-ant-admin-primary") + } + + @Test + func `routes Claude token account admin keys into admin api environment`() { + let env = TokenAccountSupportCatalog.envOverride(for: .claude, token: "Bearer sk-ant-admin-token") + + #expect(env?[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == "sk-ant-admin-token") + } + + @Test + func `auto source uses configured admin api key`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(sourceMode: .auto) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["claude.admin-api"]) + } + + @Test + func `parses Anthropic admin cost and messages usage into daily summaries`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let costs = """ + { + "data": [ + { + "starting_at": "2023-11-14T00:00:00Z", + "ending_at": "2023-11-15T00:00:00Z", + "results": [ + { + "currency": "USD", + "amount": "12345.00", + "description": "Claude Sonnet 4 Usage - Input Tokens", + "cost_type": "tokens" + }, + { + "currency": "USD", + "amount": "2500.00", + "description": "Web Search Usage", + "cost_type": "web_search" + } + ] + }, + { + "starting_at": "2023-11-15T00:00:00Z", + "ending_at": "2023-11-16T00:00:00Z", + "results": [ + { + "currency": "USD", + "amount": "5000", + "description": "Claude Haiku Usage - Output Tokens", + "cost_type": "tokens" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + let messages = """ + { + "data": [ + { + "starting_at": "2023-11-14T00:00:00Z", + "ending_at": "2023-11-15T00:00:00Z", + "results": [ + { + "uncached_input_tokens": 1500, + "cache_creation": { + "ephemeral_1h_input_tokens": 1000, + "ephemeral_5m_input_tokens": 500 + }, + "cache_read_input_tokens": 200, + "output_tokens": 500, + "model": "claude-sonnet-4-20250514" + }, + { + "uncached_input_tokens": 100, + "output_tokens": 50, + "model": "claude-opus-4-20250514" + } + ] + }, + { + "starting_at": "2023-11-15T00:00:00Z", + "ending_at": "2023-11-16T00:00:00Z", + "results": [ + { + "uncached_input_tokens": 200, + "cache_read_input_tokens": 300, + "output_tokens": 100, + "model": "claude-sonnet-4-20250514" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + let snapshot = try ClaudeAdminAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + messages: Data(messages.utf8), + now: now) + + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[0].costUSD == 148.45) + #expect(snapshot.daily[0].inputTokens == 1600) + #expect(snapshot.daily[0].cacheCreationInputTokens == 1500) + #expect(snapshot.daily[0].cacheReadInputTokens == 200) + #expect(snapshot.daily[0].outputTokens == 550) + #expect(snapshot.daily[0].totalTokens == 3850) + #expect(snapshot.last30Days.costUSD == 198.45) + #expect(snapshot.last30Days.totalTokens == 4450) + #expect(snapshot.topModels.first?.name == "claude-sonnet-4-20250514") + #expect(snapshot.topModels.first?.totalTokens == 4300) + } + + @Test + func `maps Anthropic admin usage to Claude usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [], + 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.claudeAdminAPIUsage?.last30Days.totalTokens == 1950) + #expect(usage.identity?.providerID == .claude) + #expect(usage.identity?.loginMethod == "Admin API") + } + + @Test + func `fetch strategy reports admin api source label`() async throws { + let strategy = ClaudeAdminAPIFetchStrategy(usageFetcher: { apiKey in + #expect(apiKey == "sk-ant-admin-test") + return ClaudeAdminAPIUsageSnapshot(daily: [], updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "admin-api") + #expect(result.usage.identity?.loginMethod == "Admin API") + } +} diff --git a/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift new file mode 100644 index 000000000..3acbf2686 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLITimeoutRetryTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeCLITimeoutRetryTests { + private actor AttemptRecorder { + private var count = 0 + private var timeouts: [TimeInterval] = [] + + func record(timeout: TimeInterval) -> Int { + self.count += 1 + self.timeouts.append(timeout) + return self.count + } + + func snapshot() -> (count: Int, timeouts: [TimeInterval]) { + (self.count, self.timeouts) + } + } + + @Test + func `cli usage retries with longer timeout after transient probe failure`() async throws { + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .cli) + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + let attempt = await attempts.record(timeout: timeout) + if attempt == 1 { + throw ClaudeStatusProbeError.timedOut + } + return ClaudeStatusSnapshot( + sessionPercentLeft: 91, + weeklyPercentLeft: 88, + opusPercentLeft: nil, + accountEmail: "cli@example.com", + accountOrganization: "CLI Org", + loginMethod: "cli", + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "probe raw") + } + + let snapshot = try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + + let recorded = await attempts.snapshot() + #expect(recorded.count == 2) + #expect(recorded.timeouts == [24, 60]) + #expect(snapshot.primary.usedPercent == 9) + #expect(snapshot.secondary?.usedPercent == 12) + #expect(snapshot.accountEmail == "cli@example.com") + } + + @Test + func `cli usage does not retry cancelled probe`() async throws { + let attempts = AttemptRecorder() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .cli) + + let fetchOverride: ClaudeStatusProbe.FetchOverride = { _, timeout, _ in + _ = await attempts.record(timeout: timeout) + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/usr/bin/true") { + try await ClaudeStatusProbe.withFetchOverrideForTesting(fetchOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + + let recorded = await attempts.snapshot() + #expect(recorded.count == 1) + #expect(recorded.timeouts == [24]) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index 1e49708b0..c9a167f64 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -763,50 +763,55 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { 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.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-load-with-prompt", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 321, - createdAt: 320, - persistentRefHash: "sentinel") + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + 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 securityData = self.makeCredentialsData( + accessToken: "security-load-with-prompt", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 321, + createdAt: 320, + persistentRefHash: "sentinel") - let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.userInitiated) { - try ClaudeOAuthCredentialsStore - .withClaudeKeychainFingerprintStoreOverrideForTesting( - fingerprintStore) - { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: nil, - fingerprint: sentinelFingerprint) - { - try ClaudeOAuthCredentialsStore - .withSecurityCLIReadOverrideForTesting( - .data(securityData)) - { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: true, - respectKeychainPromptCooldown: false) - } - } + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } } - } - } - }) + } + }) - #expect(creds.accessToken == "security-load-with-prompt") - #expect(fingerprintStore.fingerprint == nil) + #expect(creds.accessToken == "security-load-with-prompt") + #expect(fingerprintStore.fingerprint == nil) + } + } } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 46a998fe5..efc26ca56 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -336,28 +336,30 @@ struct ClaudeOAuthCredentialsStoreTests { .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { - ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - defer { KeychainCacheStore.clear(key: cacheKey) } + 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 validData = self.makeCredentialsData( - accessToken: "legacy-owner", - expiresAt: Date(timeIntervalSinceNow: 3600), - refreshToken: "refresh-token") - KeychainCacheStore.store( - key: cacheKey, - entry: ClaudeOAuthCredentialsStore.CacheEntry( - data: validData, - storedAt: Date())) - - let record = try ClaudeOAuthCredentialsStore.loadRecord( - environment: [:], - allowKeychainPrompt: false, - respectKeychainPromptCooldown: true) - #expect(record.owner == .claudeCLI) - #expect(record.source == .cacheKeychain) + let validData = self.makeCredentialsData( + accessToken: "legacy-owner", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: validData, + storedAt: Date())) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index d62d74207..e180075db 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -12,7 +12,8 @@ struct ClaudeOAuthTests { "refreshToken": "test-refresh", "expiresAt": 4102444800000, "scopes": ["usage:read"], - "rateLimitTier": "default_claude_max_20x" + "rateLimitTier": "default_claude_max_20x", + "subscriptionType": "pro" } } """ @@ -21,6 +22,7 @@ struct ClaudeOAuthTests { #expect(creds.refreshToken == "test-refresh") #expect(creds.scopes == ["usage:read"]) #expect(creds.rateLimitTier == "default_claude_max_20x") + #expect(creds.subscriptionType == "pro") #expect(creds.isExpired == false) } @@ -81,6 +83,20 @@ struct ClaudeOAuthTests { #expect(snap.loginMethod == "Claude Pro") } + @Test + func `maps O auth subscription type when rate limit tier is generic`() throws { + let json = """ + { + "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + rateLimitTier: "default_claude_ai", + subscriptionType: "pro") + #expect(snap.loginMethod == "Claude Pro") + } + @Test func `maps O auth design and routines usage windows`() throws { let json = """ @@ -160,6 +176,7 @@ struct ClaudeOAuthTests { #expect(snap.providerCost?.currencyCode == "USD") #expect(snap.providerCost?.limit == 20.5) #expect(snap.providerCost?.used == 3.25) + #expect(snap.providerCost?.period == "Monthly cap") } @Test @@ -179,6 +196,86 @@ struct ClaudeOAuthTests { #expect(snap.providerCost?.currencyCode == "USD") #expect(snap.providerCost?.limit == 20) #expect(snap.providerCost?.used == 5.2) + #expect(snap.providerCost?.period == "Monthly cap") + } + + @Test + func `maps enterprise O auth spend limit without session windows`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + #expect(snap.loginMethod == "Claude Enterprise") + #expect(snap.primary.usedPercent == 72) + #expect(snap.primaryWindowKind == .spendLimit) + #expect(snap.primary.windowMinutes == nil) + #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.secondary == nil) + #expect(snap.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 600) + #expect(snap.providerCost?.used == 434.43) + + let usage = ClaudeOAuthFetchStrategy._snapshotForTesting(from: snap) + #expect(usage.primary == nil) + #expect(usage.providerCost?.period == "Spend limit") + #expect(usage.providerCost?.used == 434.43) + } + + @Test + func `maps O auth spend limit without plan metadata as major units`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + 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.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 600) + #expect(snap.providerCost?.used == 434.43) + } + + @Test + func `maps large enterprise O auth spend limit as major units`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 10000, + "used_credits": 1234.56, + "utilization": 12.3456, + "currency": "USD" + } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + #expect(snap.primaryWindowKind == .spendLimit) + #expect(snap.primary.usedPercent == 12.3456) + #expect(snap.primary.resetDescription == "Spend limit: $1,234.56 / $10,000.00") + #expect(snap.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 10000) + #expect(snap.providerCost?.used == 1234.56) } @Test diff --git a/Tests/CodexBarTests/ClaudePeakHoursTests.swift b/Tests/CodexBarTests/ClaudePeakHoursTests.swift new file mode 100644 index 000000000..521ef052c --- /dev/null +++ b/Tests/CodexBarTests/ClaudePeakHoursTests.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudePeakHoursTests { + private static let eastern = TimeZone(identifier: "America/New_York")! + + private func date( + year: Int = 2026, + month: Int = 3, + day: Int, + hour: Int, + minute: Int = 0, + second: Int = 0) -> Date + { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = Self.eastern + return cal.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second))! + } + + @Test + func `weekday morning before peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1h") + } + + @Test + func `weekday just before peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func `weekday peak start`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } + + @Test + func `weekday mid peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 11, minute: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h 30m") + } + + @Test + func `weekday peak end boundary`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 13, minute: 59)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1m") + } + + @Test + func `weekday after peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 14)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 18h") + } + + @Test + func `weekday late evening`() { + let status = ClaudePeakHours.status(at: self.date(day: 26, hour: 23)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 9h") + } + + @Test + func `saturday morning`() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 46h") + } + + @Test + func `sunday evening`() { + let status = ClaudePeakHours.status(at: self.date(day: 29, hour: 21)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 11h") + } + + @Test + func `friday after peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 15)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 65h") + } + + @Test + func `friday peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 12)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h") + } + + @Test + func `spring forward weekend`() { + let status = ClaudePeakHours.status(at: self.date(day: 7, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 45h") + } + + @Test + func `monday midnight`() { + let status = ClaudePeakHours.status(at: self.date(day: 23, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 8h") + } + + @Test + func `peak with minute granularity`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 12, minute: 15)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1h 45m") + } + + @Test + func `saturday midnight`() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 56h") + } + + @Test + func `weekday just before peak with seconds`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func `weekday one minute before peak with seconds`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func `weekday last second before peak`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 59)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func `weekday peak start with seconds`() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8, minute: 0, second: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } +} diff --git a/Tests/CodexBarTests/ClaudePlanResolverTests.swift b/Tests/CodexBarTests/ClaudePlanResolverTests.swift index f3a59a57a..caeb2d17d 100644 --- a/Tests/CodexBarTests/ClaudePlanResolverTests.swift +++ b/Tests/CodexBarTests/ClaudePlanResolverTests.swift @@ -11,6 +11,17 @@ struct ClaudePlanResolverTests { #expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_enterprise") == "Claude Enterprise") } + @Test + func `oauth subscription type overrides generic rate limit tier`() { + #expect( + ClaudePlan.oauthLoginMethod(subscriptionType: "pro", rateLimitTier: "default_claude_ai") + == "Claude Pro") + #expect( + ClaudePlan.oauthLoginMethod(subscriptionType: "team", rateLimitTier: "default_claude_max_5x") + == "Claude Team") + #expect(ClaudePlan.oauthLoginMethod(subscriptionType: nil, rateLimitTier: "default_claude_ai") == nil) + } + @Test func `web fallback preserves stripe Claude compatibility`() { #expect( diff --git a/Tests/CodexBarTests/ClaudeResilienceTests.swift b/Tests/CodexBarTests/ClaudeResilienceTests.swift index 66e524507..aa2c851d2 100644 --- a/Tests/CodexBarTests/ClaudeResilienceTests.swift +++ b/Tests/CodexBarTests/ClaudeResilienceTests.swift @@ -1,5 +1,7 @@ +import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct ClaudeResilienceTests { @Test @@ -26,4 +28,1121 @@ struct ClaudeResilienceTests { let shouldSurface = gate.shouldSurfaceError(onFailureWithPriorData: true) #expect(shouldSurface == false) } + + @Test + func `timeout keeps prior Claude snapshot without surfacing repeated 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, prior) = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-timeout-cache") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = 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")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in [TimeoutFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return (store, 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, + hasError: store.error(for: .claude) != nil) + } + + #expect(secondResult.updatedAt == prior.updatedAt) + #expect(!secondResult.hasError) + } + } + } + + @Test + func `repeated non probe transient failure still surfaces`() 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, prior) = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-network-cache") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in [NetworkLostFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return (store, 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, + hasError: store.error(for: .claude) != nil) + } + + #expect(secondResult.updatedAt == prior.updatedAt) + #expect(secondResult.hasError) + } + } + } + + @Test + func `credentials change clears prior Claude snapshot for non transient failure`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + try Data("{}".utf8).write(to: fileURL) + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [AuthFailureFetchStrategy(credentialsFileURL: fileURL)] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasError: store.error(for: .claude) != nil) + } + + #expect(!result.hasSnapshot) + #expect(result.hasError) + } + } + } + } + + @Test + func `credentials change clears prior Claude snapshot for transient failure`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + try Data("{}".utf8).write(to: fileURL) + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-transient-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [TransientFailureAfterCredentialSwapFetchStrategy(credentialsFileURL: fileURL)] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasError: store.error(for: .claude) != nil) + } + + #expect(!result.hasSnapshot) + #expect(result.hasError) + } + } + } + } + + @Test + func `keychain change clears prior Claude snapshot for transient failure`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let storedFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "old") + let currentFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "new") + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore( + fingerprint: storedFingerprint) + + try await ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: currentFingerprint) + { + 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 { + let settings = Self.makeSettingsStore( + suite: "ClaudeResilienceTests-keychain-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [TimeoutFetchStrategy()] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasError: store.error(for: .claude) != nil) + } + + #expect(!result.hasSnapshot) + #expect(result.hasError) + } + } + } + } + } + } + } + } + } + } + + @Test + func `keychain removal clears prior Claude snapshot for transient failure`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let storedFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "old") + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore( + fingerprint: storedFingerprint) + let keychainStore = ClaudeOAuthCredentialsStore.ClaudeKeychainOverrideStore(data: nil, fingerprint: nil) + + try await ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withMutableClaudeKeychainOverrideStoreForTesting( + keychainStore) + { + 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 { + let settings = Self.makeSettingsStore( + suite: "ClaudeResilienceTests-keychain-auth-removal") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [TimeoutFetchStrategy()] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasError: store.error(for: .claude) != nil, + storedFingerprint: fingerprintStore.fingerprint) + } + + #expect(!result.hasSnapshot) + #expect(result.hasError) + #expect(result.storedFingerprint == nil) + } + } + } + } + } + } + } + } + } + } +} + +extension ClaudeResilienceTests { + @Test + func `keychain probe denial preserves prior Claude snapshot for transient failure`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let storedFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "old") + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore( + fingerprint: storedFingerprint) + let deniedStore = ClaudeOAuthKeychainAccessGate.DeniedUntilStore() + deniedStore.deniedUntil = Date(timeIntervalSinceNow: 3600) + + try await ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try await ClaudeOAuthKeychainAccessGate.withDeniedUntilStoreOverrideForTesting(deniedStore) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + 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, prior) = try await MainActor.run { + let settings = Self.makeSettingsStore( + suite: "ClaudeResilienceTests-keychain-denial") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in [TimeoutFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return (store, prior) + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + hasError: store.error(for: .claude) != nil, + storedFingerprint: fingerprintStore.fingerprint) + } + + #expect(result.updatedAt == prior.updatedAt) + #expect(!result.hasError) + #expect(result.storedFingerprint == storedFingerprint) + } + } + } + } + } + } + } + + @Test + func `keychain change clears once then preserves later reset backfill`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let resetDate = Date(timeIntervalSince1970: 1_900_000_000) + let storedFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 1, + createdAt: 1, + persistentRefHash: "old") + let currentFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 2, + createdAt: 1, + persistentRefHash: "new") + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore( + fingerprint: storedFingerprint) + + try await ClaudeOAuthCredentialsStore.withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(false) { + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(true) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: currentFingerprint) + { + 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 { + let settings = Self.makeSettingsStore( + suite: "ClaudeResilienceTests-keychain-auth-consumed") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: resetDate, + resetDescription: "old reset"), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: nil) + store._setSnapshotForTesting(prior, provider: .claude) + store.lastKnownResetSnapshots[.claude] = prior + + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [SuccessfulFetchStrategy()] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let firstReset = await MainActor.run { + store.snapshot(for: .claude)?.primary?.resetsAt + } + #expect(firstReset == nil) + #expect(fingerprintStore.fingerprint == currentFingerprint) + + await MainActor.run { + let seed = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: resetDate, + resetDescription: "fresh reset"), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_050), + identity: nil) + store.lastKnownResetSnapshots[.claude] = seed + } + + await store.refreshProvider(.claude) + let secondReset = await MainActor.run { + store.snapshot(for: .claude)?.primary?.resetsAt + } + + #expect(secondReset == resetDate) + } + } + } + } + } + } + } + } + } + } + + @Test + func `credentials change before fetch clears stale reset backfill`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + try Data("{\"old\":true}".utf8).write(to: fileURL) + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + try Data("{\"new\":true,\"version\":2}".utf8).write(to: fileURL) + + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-prefetch-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000), + resetDescription: "old reset"), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: nil) + store._setSnapshotForTesting(prior, provider: .claude) + store.lastKnownResetSnapshots[.claude] = prior + + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in [SuccessfulFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let reset = await MainActor.run { + store.snapshot(for: .claude)?.primary?.resetsAt + } + + #expect(reset == nil) + } + } + } + } + + @Test + func `credentials change during successful Claude fetch applies fresh snapshot without stale reset`() async throws { + try await KeychainCacheStore.withServiceOverrideForTesting("com.steipete.codexbar.cache.tests.\(UUID())") { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + 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("credentials.json") + try Data("{\"old\":true}".utf8).write(to: fileURL) + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + + let store = try await MainActor.run { + let settings = Self.makeSettingsStore(suite: "ClaudeResilienceTests-midfetch-auth-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .cli + + 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: [:]) + let prior = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: Date(timeIntervalSince1970: 1_900_000_000), + resetDescription: "old reset"), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + store._setSnapshotForTesting(prior, provider: .claude) + store.lastKnownResetSnapshots[.claude] = prior + + 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: [.cli], + pipeline: ProviderFetchPipeline { _ in + [SuccessfulCredentialSwapFetchStrategy(credentialsFileURL: fileURL)] + }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + hasError: store.error(for: .claude) != nil, + reset: store.lastKnownResetSnapshots[.claude]?.primary?.resetsAt) + } + + #expect(result.hasSnapshot) + #expect(!result.hasError) + #expect(result.reset == nil) + } + } + } + } + + @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 TimeoutFetchStrategy: ProviderFetchStrategy { + let id = "test.timeout" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeStatusProbeError.timedOut + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct NetworkLostFetchStrategy: ProviderFetchStrategy { + let id = "test.network-lost" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw URLError(.networkConnectionLost) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct AuthFailureFetchStrategy: ProviderFetchStrategy { + let id = "test.auth-failure" + let kind: ProviderFetchKind = .cli + let credentialsFileURL: URL + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + try Data("{\"updated\":true}".utf8).write(to: self.credentialsFileURL) + _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + throw ClaudeUsageError.oauthFailed("Claude auth failed.") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct TransientFailureAfterCredentialSwapFetchStrategy: ProviderFetchStrategy { + let id = "test.transient-credential-swap" + let kind: ProviderFetchKind = .cli + let credentialsFileURL: URL + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + try Data("{\"updated\":true}".utf8).write(to: self.credentialsFileURL) + throw ClaudeStatusProbeError.timedOut + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct SuccessfulCredentialSwapFetchStrategy: ProviderFetchStrategy { + let id = "test.successful-credential-swap" + let kind: ProviderFetchKind = .cli + let credentialsFileURL: URL + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + try Data("{\"updated\":true}".utf8).write(to: self.credentialsFileURL) + return self.makeResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100), + identity: nil), + sourceLabel: "CLI") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct SuccessfulFetchStrategy: ProviderFetchStrategy { + let id = "test.successful" + let kind: ProviderFetchKind = .cli + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + self.makeResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_800_000_100), + identity: nil), + sourceLabel: "CLI") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } } diff --git a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift index af8846823..06cb71eba 100644 --- a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift @@ -32,7 +32,11 @@ struct ClaudeUsageDelegatedRefreshEnvironmentTests { try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( loadCredsOverride, operation: { - try await fetcher.loadLatestUsage(model: "sonnet") + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue( + false, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) }) }) } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 6e400fd6b..b52ed973d 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -721,7 +721,7 @@ struct ClaudeUsageTests { #expect(cost?.currencyCode == "EUR") #expect(cost?.limit == 20) #expect(cost?.used == 0) - #expect(cost?.period == "Monthly") + #expect(cost?.period == "Monthly cap") } @Test @@ -882,6 +882,47 @@ struct ClaudeUsageTests { } } +struct ClaudeOAuthUsageMappingTests { + @Test + func `oauth usage falls back to weekly window when five hour is absent`() throws { + let json = """ + { + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 17, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 42) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + #expect(snapshot.secondary?.usedPercent == 42) + #expect(snapshot.opus?.usedPercent == 17) + } + + @Test + func `oauth usage falls back when five hour has no utilization`() throws { + let json = """ + { + "five_hour": { "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 9, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 9) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + } + + @Test + func `oauth usage throws when no usable windows are present`() { + let json = "{}" + + #expect(throws: ClaudeUsageError.self) { + try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + } + } +} + @Suite(.serialized) struct ClaudeAutoFetcherCharacterizationTests { private final class RequestLog: @unchecked Sendable { @@ -933,7 +974,7 @@ struct ClaudeAutoFetcherCharacterizationTests { LOG_FILE='\(logURL.path)' while IFS= read -r line; do case "$line" in - "/usage") + *"/usage"*) printf 'usage\\n' >> "$LOG_FILE" cat <<'EOF' Current session @@ -944,7 +985,7 @@ struct ClaudeAutoFetcherCharacterizationTests { Dec 29 at 11:00PM EOF ;; - "/status") + *"/status"*) printf 'status\\n' >> "$LOG_FILE" cat <<'EOF' Account: cli@example.com @@ -961,24 +1002,6 @@ struct ClaudeAutoFetcherCharacterizationTests { return scriptURL } - private func withClaudeCLIPath(_ path: String?, operation: () async throws -> T) async rethrows -> T { - let key = "CLAUDE_CLI_PATH" - let original = getenv(key).map { String(cString: $0) } - if let path { - setenv(key, path, 1) - } else { - unsetenv(key) - } - defer { - if let original { - setenv(key, original, 1) - } else { - unsetenv(key) - } - } - return try await operation() - } - private func withNoOAuthCredentials(operation: () async throws -> T) async rethrows -> T { let missingCredentialsURL = FileManager.default.temporaryDirectory .appendingPathComponent("missing-claude-creds-\(UUID().uuidString).json") @@ -1017,7 +1040,7 @@ struct ClaudeAutoFetcherCharacterizationTests { return try await operation() } - private static func makeJSONResponse( + fileprivate static func makeJSONResponse( url: URL, body: String, statusCode: Int = 200) -> (HTTPURLResponse, Data) @@ -1048,25 +1071,27 @@ struct ClaudeAutoFetcherCharacterizationTests { dataSource: .auto, manualCookieHeader: "sessionKey=sk-ant-session-token") - try await self.withClaudeCLIPath(fakeCLI.path) { - try await self.withClaudeWebStub(handler: { request in - webRequests.append(request.url?.path ?? "") - let url = try #require(request.url) - return Self.makeJSONResponse(url: url, body: "{}") - }, operation: { - let fetchOverride: @Sendable (String) async throws -> OAuthUsageResponse = { _ in usageResponse } - let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue( - fetchOverride, - operation: { - try await fetcher.loadLatestUsage(model: "sonnet") - }) + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + try await self.withClaudeWebStub(handler: { request in + webRequests.append(request.url?.path ?? "") + let url = try #require(request.url) + return Self.makeJSONResponse(url: url, body: "{}") + }, operation: { + let fetchOverride: @Sendable (String) async throws -> OAuthUsageResponse = { _ in usageResponse } + let snapshot = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue( + fetchOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) - #expect(snapshot.primary.usedPercent == 7) - #expect(snapshot.secondary?.usedPercent == 21) - #expect(log.contents().isEmpty) - let requests = webRequests.current() - #expect(requests.isEmpty) - }) + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.secondary?.usedPercent == 21) + #expect(log.contents().isEmpty) + let requests = webRequests.current() + #expect(requests.isEmpty) + }) + } } } @@ -1083,61 +1108,63 @@ struct ClaudeAutoFetcherCharacterizationTests { dataSource: .auto, manualCookieHeader: "sessionKey=sk-ant-session-token") - try await self.withClaudeCLIPath(fakeCLI.path) { - try await self.withNoOAuthCredentials { - try await self.withClaudeWebStub(handler: { request in - let url = try #require(request.url) - switch url.path { - case "/api/organizations": - return Self.makeJSONResponse( - url: url, - body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) - case "/api/organizations/org-123/usage": - let body = """ - { - "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, - "seven_day_opus": { "utilization": 33 } - } - """ - return Self.makeJSONResponse( - url: url, - body: body) - case "/api/account": - let body = """ - { - "email_address": "web@example.com", - "memberships": [ + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + try await self.withNoOAuthCredentials { + try await self.withClaudeWebStub(handler: { request in + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return Self.makeJSONResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) + case "/api/organizations/org-123/usage": + let body = """ { - "organization": { - "uuid": "org-123", - "name": "Test Org", - "rate_limit_tier": "claude_max", - "billing_type": "stripe" - } + "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_opus": { "utilization": 33 } } - ] + """ + return Self.makeJSONResponse( + url: url, + body: body) + case "/api/account": + let body = """ + { + "email_address": "web@example.com", + "memberships": [ + { + "organization": { + "uuid": "org-123", + "name": "Test Org", + "rate_limit_tier": "claude_max", + "billing_type": "stripe" + } + } + ] + } + """ + return Self.makeJSONResponse( + url: url, + body: body) + case "/api/organizations/org-123/overage_spend_limit": + let body = """ + {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} + """ + return Self.makeJSONResponse( + url: url, + body: body) + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) } - """ - return Self.makeJSONResponse( - url: url, - body: body) - case "/api/organizations/org-123/overage_spend_limit": - let body = """ - {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} - """ - return Self.makeJSONResponse( - url: url, - body: body) - default: - return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) - } - }, operation: { - let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + }, operation: { + let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") - #expect(snapshot.rawText != nil) - #expect(log.contents().contains("usage")) - }) + #expect(snapshot.rawText != nil) + #expect(log.contents().contains("usage")) + }) + } } } } @@ -1155,59 +1182,61 @@ struct ClaudeAutoFetcherCharacterizationTests { dataSource: .auto, manualCookieHeader: "sessionKey=sk-ant-session-token") - try await self.withClaudeCLIPath(fakeCLI.path) { - try await self.withNoOAuthCredentials { - try await self.withClaudeWebStub(handler: { request in - let url = try #require(request.url) - switch url.path { - case "/api/organizations": - return Self.makeJSONResponse( - url: url, - body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) - case "/api/organizations/org-123/usage": - let body = """ - { - "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, - "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, - "seven_day_opus": { "utilization": 33 } - } - """ - return Self.makeJSONResponse(url: url, body: body) - case "/api/account": - let body = """ - { - "email_address": "web@example.com", - "memberships": [ + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + try await self.withNoOAuthCredentials { + try await self.withClaudeWebStub(handler: { request in + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + return Self.makeJSONResponse( + url: url, + body: #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"#) + case "/api/organizations/org-123/usage": + let body = """ { - "organization": { - "uuid": "org-123", - "name": "Test Org", - "rate_limit_tier": "claude_max", - "billing_type": "stripe" - } + "five_hour": { "utilization": 11, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 22, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_opus": { "utilization": 33 } } - ] + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/account": + let body = """ + { + "email_address": "web@example.com", + "memberships": [ + { + "organization": { + "uuid": "org-123", + "name": "Test Org", + "rate_limit_tier": "claude_max", + "billing_type": "stripe" + } + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/organizations/org-123/overage_spend_limit": + let body = """ + {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} + """ + return Self.makeJSONResponse(url: url, body: body) + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) } - """ - return Self.makeJSONResponse(url: url, body: body) - case "/api/organizations/org-123/overage_spend_limit": - let body = """ - {"monthly_credit_limit":5000,"currency":"USD","used_credits":1200,"is_enabled":true} - """ - return Self.makeJSONResponse(url: url, body: body) - default: - return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) - } - }, operation: { - let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") - - #expect(snapshot.primary.usedPercent == 11) - #expect(snapshot.secondary?.usedPercent == 22) - #expect(snapshot.opus?.usedPercent == 33) - #expect(snapshot.accountEmail == "web@example.com") - #expect(snapshot.loginMethod == "Claude Max") - #expect(log.contents().isEmpty) - }) + }, operation: { + let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + + #expect(snapshot.primary.usedPercent == 11) + #expect(snapshot.secondary?.usedPercent == 22) + #expect(snapshot.opus?.usedPercent == 33) + #expect(snapshot.accountEmail == "web@example.com") + #expect(snapshot.loginMethod == "Claude Max") + #expect(log.contents().isEmpty) + }) + } } } } @@ -1288,7 +1317,93 @@ final class ClaudeAutoFetcherStubURLProtocol: URLProtocol { override func stopLoading() {} } +extension ClaudeAutoFetcherCharacterizationTests { + @Test + func `web fetcher uses configured target organization`() async throws { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + dataSource: .web, + manualCookieHeader: "sessionKey=sk-ant-session-token", + webOrganizationID: "org-team") + + try await self.withClaudeWebStub(handler: { request in + let url = try #require(request.url) + switch url.path { + case "/api/organizations": + let body = """ + [ + { "uuid": "org-personal", "name": "Personal", "capabilities": ["chat"] }, + { "uuid": "org-team", "name": "Team Org", "capabilities": ["chat"] } + ] + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/organizations/org-team/usage": + let body = """ + { + "five_hour": { "utilization": 14, "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 28, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/account": + let body = """ + { + "email_address": "linked@example.com", + "memberships": [ + { + "organization": { + "uuid": "org-personal", + "name": "Personal", + "rate_limit_tier": "claude_max", + "billing_type": "stripe" + } + }, + { + "organization": { + "uuid": "org-team", + "name": "Team Org", + "rate_limit_tier": "enterprise", + "billing_type": "invoice" + } + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: body) + case "/api/organizations/org-team/overage_spend_limit": + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + }, operation: { + let snapshot = try await fetcher.loadLatestUsage(model: "sonnet") + + #expect(snapshot.primary.usedPercent == 14) + #expect(snapshot.secondary?.usedPercent == 28) + #expect(snapshot.accountOrganization == "Team Org") + #expect(snapshot.accountEmail == "linked@example.com") + #expect(snapshot.loginMethod == "Claude Enterprise") + }) + } +} + extension ClaudeUsageTests { + @Test + func `parses claude web API organizations honors target organization`() throws { + let json = """ + [ + { "uuid": "org-personal", "name": "Personal", "capabilities": ["chat"] }, + { "uuid": "org-team", "name": "Team", "capabilities": ["chat"] } + ] + """ + let data = Data(json.utf8) + let org = try ClaudeWebAPIFetcher._parseOrganizationsResponseForTesting( + data, + targetOrganizationID: "org-team") + #expect(org.id == "org-team") + #expect(org.name == "Team") + } + @Test func `oauth delegated retry experimental background ignores only on user action suppression`() async throws { let loadCounter = AsyncCounter() diff --git a/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift new file mode 100644 index 000000000..dcd5e1ddd --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebEnterpriseUsageTests { + @Test + func `parses usage response when session window is null`() throws { + let json = """ + { + "five_hour": null, + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.sessionPercentUsed == 0) + #expect(parsed.weeklyPercentUsed == 42) + } + + @Test + func `parses enterprise credit spend from usage response`() throws { + let json = """ + { + "five_hour": null, + "seven_day": null, + "extra_usage": { + "monthly_limit": 100000, + "used_credits": 4132 + } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + + #expect(parsed.sessionPercentUsed == 0) + #expect(parsed.sessionResetsAt == nil) + #expect(parsed.weeklyPercentUsed == nil) + #expect(parsed.extraUsageCost?.used == 41.32) + #expect(parsed.extraUsageCost?.limit == 1000) + #expect(parsed.extraUsageCost?.currencyCode == "USD") + #expect(parsed.extraUsageCost?.period == "Monthly cap") + } +} diff --git a/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift b/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift new file mode 100644 index 000000000..d86d6e3be --- /dev/null +++ b/Tests/CodexBarTests/CodebuffSettingsReaderTests.swift @@ -0,0 +1,110 @@ +import CodexBarCore +import Foundation +import Testing + +struct CodebuffSettingsReaderTests { + @Test + func `api URL defaults to www codebuff com`() { + let url = CodebuffSettingsReader.apiURL(environment: [:]) + #expect(url.scheme == "https") + #expect(url.host() == "www.codebuff.com") + } + + @Test + func `api URL honors environment override`() { + let url = CodebuffSettingsReader.apiURL(environment: [ + "CODEBUFF_API_URL": "https://staging.codebuff.com", + ]) + #expect(url.host() == "staging.codebuff.com") + } + + @Test + func `api key reads from CODEBUFF_API_KEY and trims wrapping whitespace`() { + let token = CodebuffSettingsReader.apiKey(environment: [ + CodebuffSettingsReader.apiTokenKey: " cb-test-token ", + ]) + #expect(token == "cb-test-token") + } + + @Test + func `api key strips surrounding quotes`() { + let token = CodebuffSettingsReader.apiKey(environment: [ + CodebuffSettingsReader.apiTokenKey: "\"cb-test-token\"", + ]) + #expect(token == "cb-test-token") + } + + @Test + func `api key returns nil for empty environment`() { + #expect(CodebuffSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `auth token parses credentials json`() throws { + let contents = #"{"authToken":"file-token","fingerprintId":"fp-1","email":"a@b.com"}"# + let url = try self.writeTempFile(named: "credentials.json", contents: contents) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let token = CodebuffSettingsReader.authToken(authFileURL: url) + #expect(token == "file-token") + } + + @Test + func `auth token parses default profile credentials json`() throws { + let contents = #"{"default":{"authToken":"default-token","fingerprintId":"fp-1","email":"a@b.com"}}"# + let url = try self.writeTempFile(named: "credentials.json", contents: contents) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let token = CodebuffSettingsReader.authToken(authFileURL: url) + #expect(token == "default-token") + } + + @Test + func `auth token returns nil for malformed credentials json`() throws { + let url = try self.writeTempFile(named: "credentials.json", contents: "{not-json}") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let token = CodebuffSettingsReader.authToken(authFileURL: url) + #expect(token == nil) + } + + @Test + func `auth token returns nil when file missing`() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("credentials.json", isDirectory: false) + #expect(CodebuffSettingsReader.authToken(authFileURL: url) == nil) + } + + @Test + func `descriptor uses codebuff dashboard URL`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff) + #expect(descriptor.metadata.dashboardURL == "https://www.codebuff.com/usage") + #expect(descriptor.metadata.displayName == "Codebuff") + #expect(descriptor.metadata.cliName == "codebuff") + } + + @Test + func `descriptor uses dedicated codebuff icon resource`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-codebuff") + } + + @Test + func `descriptor supports auto and API source modes`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .codebuff) + let expected: Set = [.auto, .api] + #expect(descriptor.fetchPlan.sourceModes == expected) + } + + // MARK: - Helpers + + private func writeTempFile(named name: String, contents: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent(name, isDirectory: false) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } +} diff --git a/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift new file mode 100644 index 000000000..f1d4fd258 --- /dev/null +++ b/Tests/CodexBarTests/CodebuffUsageFetcherTests.swift @@ -0,0 +1,387 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CodebuffUsageFetcherTests { + @Test + func `usage URL composes the correct endpoint`() throws { + let base = try #require(URL(string: "https://www.codebuff.com")) + let url = CodebuffUsageFetcher.usageURL(baseURL: base) + #expect(url.absoluteString == "https://www.codebuff.com/api/v1/usage") + } + + @Test + func `subscription URL composes the correct endpoint`() throws { + let base = try #require(URL(string: "https://www.codebuff.com")) + let url = CodebuffUsageFetcher.subscriptionURL(baseURL: base) + #expect(url.absoluteString == "https://www.codebuff.com/api/user/subscription") + } + + @Test + func `usage request sends required fingerprint id`() async throws { + defer { + CodebuffStubURLProtocol.handler = nil + CodebuffStubURLProtocol.requests = [] + CodebuffStubURLProtocol.requestBodies = [] + } + CodebuffStubURLProtocol.requests = [] + CodebuffStubURLProtocol.requestBodies = [] + CodebuffStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/api/v1/usage": + return try Self.makeResponse(url: url, body: #"{"usage":25,"quota":100,"remainingBalance":75}"#) + case "/api/user/subscription": + return try Self.makeResponse(url: url, body: "{}") + default: + return try Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let snapshot = try await CodebuffUsageFetcher.fetchUsage(apiKey: "cb-test", session: Self.makeSession()) + let usageIndex = try #require(CodebuffStubURLProtocol.requests.firstIndex { + $0.url?.path == "/api/v1/usage" + }) + let body = try #require(CodebuffStubURLProtocol.requestBodies[usageIndex]) + let payload = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + + #expect(payload["fingerprintId"] == "codexbar-usage") + #expect(snapshot.creditsUsed == 25) + } + + @Test + func `usage fetch can skip subscription endpoint for API key tokens`() async throws { + defer { + CodebuffStubURLProtocol.handler = nil + CodebuffStubURLProtocol.requests = [] + CodebuffStubURLProtocol.requestBodies = [] + } + CodebuffStubURLProtocol.requests = [] + CodebuffStubURLProtocol.requestBodies = [] + CodebuffStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/api/v1/usage": + return try Self.makeResponse(url: url, body: #"{"usage":25,"quota":100,"remainingBalance":75}"#) + case "/api/user/subscription": + Issue.record("Subscription endpoint should not be called for API key tokens") + return try Self.makeResponse(url: url, body: "{}", statusCode: 500) + default: + return try Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + } + + let snapshot = try await CodebuffUsageFetcher.fetchUsage( + apiKey: "cb-test", + includeSubscription: false, + session: Self.makeSession()) + + #expect(snapshot.creditsUsed == 25) + #expect(CodebuffStubURLProtocol.requests.map(\.url?.path) == ["/api/v1/usage"]) + } + + @Test + func `api strategy only fetches subscription for credentials file tokens`() { + let envResolution = ProviderTokenResolution(token: "env-token", source: .environment) + let fileResolution = ProviderTokenResolution(token: "file-token", source: .authFile) + + #expect(CodebuffAPIFetchStrategy.shouldFetchSubscription(for: envResolution) == false) + #expect(CodebuffAPIFetchStrategy.shouldFetchSubscription(for: fileResolution) == true) + } + + @Test + func `status 401 maps to unauthorized`() { + #expect(CodebuffUsageFetcher._statusErrorForTesting(401) == .unauthorized) + #expect(CodebuffUsageFetcher._statusErrorForTesting(403) == .unauthorized) + } + + @Test + func `status 404 maps to endpoint not found`() { + #expect(CodebuffUsageFetcher._statusErrorForTesting(404) == .endpointNotFound) + } + + @Test + func `status 500 maps to service unavailable`() { + guard case .serviceUnavailable(503) = CodebuffUsageFetcher._statusErrorForTesting(503) + else { + Issue.record("Expected .serviceUnavailable(503)") + return + } + } + + @Test + func `status 200 returns nil`() { + #expect(CodebuffUsageFetcher._statusErrorForTesting(200) == nil) + } + + @Test + func `usage payload parses numeric credit fields`() throws { + let json = """ + { + "usage": 1250, + "quota": 5000, + "remainingBalance": 3750, + "autoTopupEnabled": true, + "next_quota_reset": "2026-05-01T00:00:00Z" + } + """ + + let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8)) + #expect(payload.used == 1250) + #expect(payload.total == 5000) + #expect(payload.remaining == 3750) + #expect(payload.autoTopupEnabled == true) + #expect(payload.nextQuotaReset != nil) + } + + @Test + func `usage payload accepts string-encoded numbers`() throws { + let json = """ + { "usage": "12", "quota": "100", "remainingBalance": "88" } + """ + let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data(json.utf8)) + #expect(payload.used == 12) + #expect(payload.total == 100) + #expect(payload.remaining == 88) + } + + @Test + func `usage payload returns nil fields when absent`() throws { + let payload = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("{}".utf8)) + #expect(payload.used == nil) + #expect(payload.total == nil) + #expect(payload.remaining == nil) + #expect(payload.autoTopupEnabled == nil) + } + + @Test + func `usage payload throws on malformed JSON`() { + #expect { + _ = try CodebuffUsageFetcher._parseUsagePayloadForTesting(Data("not-json".utf8)) + } throws: { error in + guard case CodebuffUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `subscription payload parses tier and weekly window`() throws { + let json = """ + { + "hasSubscription": true, + "subscription": { + "status": "active", + "tier": "pro", + "billingPeriodEnd": "2026-05-15T00:00:00Z" + }, + "rateLimit": { + "weeklyUsed": 2100, + "weeklyLimit": 7000, + "weeklyResetsAt": "2026-05-08T00:00:00Z" + }, + "email": "user@example.com" + } + """ + + let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8)) + #expect(payload.tier == "pro") + #expect(payload.status == "active") + #expect(payload.weeklyUsed == 2100) + #expect(payload.weeklyLimit == 7000) + #expect(payload.weeklyResetsAt != nil) + #expect(payload.email == "user@example.com") + #expect(payload.billingPeriodEnd != nil) + } + + @Test + func `subscription payload prefers display name over numeric tier`() throws { + let json = """ + { "subscription": { "tier": 2, "displayName": "Pro" } } + """ + let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8)) + #expect(payload.tier == "Pro") + } + + @Test + func `subscription payload falls back to numeric scheduled tier`() throws { + let json = """ + { "subscription": { "scheduledTier": 3 } } + """ + let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8)) + #expect(payload.tier == "3") + } + + @Test + func `subscription payload formats oversized numeric tier without trapping`() throws { + let json = """ + { "subscription": { "scheduledTier": 9223372036854775808 } } + """ + let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8)) + #expect(payload.tier == "9223372036854775808") + } + + @Test + func `subscription payload tolerates missing rate limit`() throws { + let json = """ + { "subscription": { "status": "trialing", "tier": "free" } } + """ + let payload = try CodebuffUsageFetcher._parseSubscriptionPayloadForTesting(Data(json.utf8)) + #expect(payload.weeklyUsed == nil) + #expect(payload.weeklyLimit == nil) + #expect(payload.status == "trialing") + } + + @Test + func `snapshot maps to rate window with credits window`() { + let snapshot = CodebuffUsageSnapshot( + creditsUsed: 250, + creditsTotal: 1000, + creditsRemaining: 750, + weeklyUsed: 100, + weeklyLimit: 500, + weeklyResetsAt: Date(timeIntervalSince1970: 1_777_680_000), + tier: "pro", + autoTopUpEnabled: true, + updatedAt: Date()) + + let unified = snapshot.toUsageSnapshot() + #expect(unified.primary?.usedPercent == 25) + // The credit balance is intentionally NOT stored in `resetDescription` — + // generic renderers prepend "Resets " when `resetsAt` is absent, which would + // surface misleading text like "Resets 250/1,000 credits". + #expect(unified.primary?.resetDescription == nil) + #expect(unified.secondary?.usedPercent == 20) + #expect(unified.secondary?.windowMinutes == 7 * 24 * 60) + #expect(unified.secondary?.resetsAt == Date(timeIntervalSince1970: 1_777_680_000)) + #expect(unified.secondary?.resetDescription == nil) + #expect(unified.identity?.providerID == .codebuff) + #expect(unified.identity?.loginMethod?.contains("Pro") == true) + #expect(unified.identity?.loginMethod?.contains("auto top-up") == true) + } + + @Test + func `snapshot infers total from used plus remaining`() { + let snapshot = CodebuffUsageSnapshot( + creditsUsed: 40, + creditsTotal: nil, + creditsRemaining: 60) + + let unified = snapshot.toUsageSnapshot() + #expect(unified.primary?.usedPercent == 40) + } + + @Test + func `snapshot surfaces exhausted state when quota is missing from payload`() { + // Only `creditsUsed` is populated (no total, no remaining) — the API response is + // degenerate but we still want the row to be visible so the user notices the + // missing configuration instead of seeing an empty/healthy-looking bar. + let usedOnly = CodebuffUsageSnapshot( + creditsUsed: 42, + creditsTotal: nil, + creditsRemaining: nil) + #expect(usedOnly.toUsageSnapshot().primary?.usedPercent == 100) + + // Only `creditsRemaining` is populated — same fallback should apply. + let remainingOnly = CodebuffUsageSnapshot( + creditsUsed: nil, + creditsTotal: nil, + creditsRemaining: 17) + #expect(remainingOnly.toUsageSnapshot().primary?.usedPercent == 100) + } + + @Test + func `snapshot hides credit window when no credit fields are present`() { + let empty = CodebuffUsageSnapshot() + #expect(empty.toUsageSnapshot().primary == nil) + } + + @Test + func `missing credentials fetch call throws missing credentials`() async { + do { + _ = try await CodebuffUsageFetcher.fetchUsage(apiKey: " ") + Issue.record("Expected missingCredentials error") + } catch let error as CodebuffUsageError { + #expect(error == .missingCredentials) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CodebuffStubURLProtocol.self] + return URLSession(configuration: config) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) throws -> (HTTPURLResponse, Data) + { + guard let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"]) + else { + throw URLError(.badServerResponse) + } + return (response, Data(body.utf8)) + } +} + +final class CodebuffStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var requestBodies: [Data?] = [] + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "www.codebuff.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + Self.requestBodies.append(Self.bodyData(from: self.request)) + 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() {} + + private static func bodyData(from request: URLRequest) -> Data? { + if let httpBody = request.httpBody { + return httpBody + } + guard let stream = request.httpBodyStream else { return nil } + + stream.open() + defer { stream.close() } + + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count > 0 { + data.append(buffer, count: count) + } else { + break + } + } + return data.isEmpty ? nil : data + } +} diff --git a/Tests/CodexBarTests/CodexAccountFingerprintReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountFingerprintReconciliationTests.swift new file mode 100644 index 000000000..5d87dff58 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountFingerprintReconciliationTests.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct CodexAccountFingerprintReconciliationTests { + @Test + func `active source falls back to identity when auth fingerprint rotated`() throws { + let accountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-333333333333")) + let managed = ManagedCodexAccount( + id: accountID, + email: "rotated@example.com", + authFingerprint: "old-auth-json", + managedHomePath: "/tmp/rotated", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let live = ObservedSystemCodexAccount( + email: "rotated@example.com", + authFingerprint: "new-auth-json", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "rotated@example.com")) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [managed], + activeStoredAccount: managed, + liveSystemAccount: live, + matchingStoredAccountForLiveSystemAccount: managed, + activeSource: .managedAccount(id: accountID), + hasUnreadableAddedAccountStore: false) + + let resolution = CodexActiveSourceResolver.resolve(from: snapshot) + + #expect(resolution.resolvedSource == .liveSystem) + #expect(resolution.requiresPersistenceCorrection) + } + + @Test + @MainActor + func `auth fingerprint matches live account before semantic duplicate identity`() throws { + let suite = "CodexAccountFingerprintReconciliationTests-auth-fingerprint" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let firstID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let first = ManagedCodexAccount( + id: firstID, + email: "same@example.com", + authFingerprint: "1111", + managedHomePath: "/tmp/first", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let second = ManagedCodexAccount( + id: secondID, + email: "same@example.com", + providerAccountID: "account-team", + authFingerprint: "2222", + managedHomePath: "/tmp/second", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-store-\(UUID().uuidString).json") + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [first, second]), + to: storeURL) + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "same@example.com", + authFingerprint: "2222", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "same@example.com")) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + let snapshot = settings.codexAccountReconciliationSnapshot + let projection = settings.codexVisibleAccountProjection + + #expect(snapshot.matchingStoredAccountForLiveSystemAccount?.id == secondID) + #expect(projection.liveVisibleAccountID == "live:email:same@example.com") + #expect(projection.visibleAccounts.first { $0.storedAccountID == secondID }?.isLive == true) + #expect(projection.visibleAccounts.first { $0.storedAccountID == firstID }?.isLive == false) + } + + private static func writeManagedCodexStore(_ accounts: ManagedCodexAccountSet, to storeURL: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(accounts) + try data.write(to: storeURL, options: [.atomic]) + } +} diff --git a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift index b489bebf1..48a867c23 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionServiceTests.swift @@ -72,6 +72,7 @@ struct CodexAccountPromotionServiceTests { #expect(imported.providerAccountID == "acct-alpha") #expect(imported.workspaceLabel == "Personal") #expect(imported.workspaceAccountID == "acct-alpha") + #expect(imported.authFingerprint == CodexAuthFingerprint.fingerprint(data: displacedLiveAuthData)) #expect(try container.managedAuthData(for: imported) == displacedLiveAuthData) #expect(try container.liveAuthData() == container.managedAuthData(for: target)) } @@ -103,6 +104,7 @@ struct CodexAccountPromotionServiceTests { #expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: existingManagedLive.id)) #expect(accounts.count == 2) #expect(accounts.contains(where: { $0.id == existingManagedLive.id })) + #expect(refreshedManagedLive.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData)) #expect(try container.managedAuthData(for: refreshedManagedLive) == liveAuthData) #expect(try container.liveAuthData() == container.managedAuthData(for: target)) } @@ -129,6 +131,7 @@ struct CodexAccountPromotionServiceTests { #expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: existingManagedLive.id)) #expect(refreshedManagedLive.email == "alpha@example.com") #expect(refreshedManagedLive.providerAccountID == "acct-alpha") + #expect(refreshedManagedLive.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData)) #expect(try container.managedAuthData(for: refreshedManagedLive) == liveAuthData) } @@ -455,6 +458,7 @@ struct CodexAccountPromotionServiceTests { let accounts = try container.loadAccounts().accounts let imported = try #require(accounts.first(where: { $0.id != target.id })) #expect(accounts.count == 2) + #expect(imported.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData)) #expect(try container.managedAuthData(for: imported) == liveAuthData) #expect(try container.liveAuthData() == liveAuthData) #expect(container.settings.codexActiveSource == .managedAccount(id: target.id)) @@ -519,6 +523,7 @@ struct CodexAccountPromotionServiceTests { #expect(result.displacedLiveDisposition == .alreadyManaged(managedAccountID: staleManaged.id)) #expect(accounts.count == 2) #expect(repairedManaged.managedHomePath == staleHomeURL.path) + #expect(repairedManaged.authFingerprint == CodexAuthFingerprint.fingerprint(data: liveAuthData)) #expect(try container.managedAuthData(for: repairedManaged) == liveAuthData) #expect(try container.managedHomeURLs().count == 2) } diff --git a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift index b4598f4cd..824439a7c 100644 --- a/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountPromotionTestSupport.swift @@ -137,7 +137,7 @@ final class CodexAccountPromotionTestContainer { { let homeURL = self.managedHomesURL.appendingPathComponent(id.uuidString, isDirectory: true) let createdAt = Date().timeIntervalSince1970 - _ = try self.writeOAuthAuthFile( + let authData = try self.writeOAuthAuthFile( homeURL: homeURL, email: authEmail ?? persistedEmail, plan: plan, @@ -154,6 +154,7 @@ final class CodexAccountPromotionTestContainer { providerAccountID: persistedProviderAccountIDValue, workspaceLabel: workspaceLabel, workspaceAccountID: workspaceAccountID ?? authAccountID, + authFingerprint: CodexAuthFingerprint.fingerprint(data: authData), managedHomePath: homeURL.path, createdAt: createdAt, updatedAt: createdAt, diff --git a/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift new file mode 100644 index 000000000..79517f800 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexAccountProviderIdentityReconciliationTests { + @Test + func `same provider account id with different email does not merge live and managed rows`() { + let stored = ManagedCodexAccount( + id: UUID(), + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: "/tmp/managed-a", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let live = ObservedSystemCodexAccount( + email: "mich.aelfmk5542@gmail.com", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "team-4107")) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [stored], + activeStoredAccount: stored, + liveSystemAccount: live, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: stored.id), + hasUnreadableAddedAccountStore: false, + storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "team-4107")], + storedAccountRuntimeEmails: [stored.id: "mi.chaelfmk5542@gmail.com"]) + + let resolution = CodexActiveSourceResolver.resolve(from: snapshot) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + + #expect(resolution.resolvedSource == .managedAccount(id: stored.id)) + #expect(projection.visibleAccounts.count == 2) + #expect(projection.visibleAccounts.map(\.email).sorted() == [ + "mi.chaelfmk5542@gmail.com", + "mich.aelfmk5542@gmail.com", + ]) + #expect(projection.activeVisibleAccountID == "mi.chaelfmk5542@gmail.com") + #expect(projection.liveVisibleAccountID == "mich.aelfmk5542@gmail.com") + } +} diff --git a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift index 85ef3a406..d9d5f256e 100644 --- a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift +++ b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift @@ -22,6 +22,52 @@ struct CodexActiveSourceConfigTests { from: Data(legacyJSON.utf8)) #expect(decoded.providerConfig(for: .codex)?.codexActiveSource == nil) + #expect(decoded.providerConfig(for: .codex)?.quotaWarnings == nil) + } + + @Test + func `provider config round trips quota warning overrides`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .codex, + quotaWarnings: QuotaWarningConfig( + session: QuotaWarningWindowConfig(thresholds: [10]), + weekly: QuotaWarningWindowConfig(thresholds: [50, 20]))), + ]) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + let quotaWarnings = try #require(decoded.providerConfig(for: .codex)?.quotaWarnings) + + #expect(quotaWarnings.thresholds(for: .session, global: [80]) == [10]) + #expect(quotaWarnings.thresholds(for: .weekly, global: [80]) == [50, 20]) + } + + @Test + func `quota warning window enabled defaults stay backward compatible`() throws { + let legacyJSON = """ + { + "version": 1, + "providers": [ + { + "id": "codex", + "quotaWarnings": { + "session": { "thresholds": [10] }, + "weekly": { "enabled": false } + } + } + ] + } + """ + + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: Data(legacyJSON.utf8)) + let quotaWarnings = try #require(decoded.providerConfig(for: .codex)?.quotaWarnings) + + #expect(quotaWarnings.isEnabled(for: .session, global: false) == true) + #expect(quotaWarnings.isEnabled(for: .weekly, global: true) == false) + #expect(quotaWarnings.hasOverride(for: .session) == true) + #expect(quotaWarnings.hasOverride(for: .weekly) == true) } @Test diff --git a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift new file mode 100644 index 000000000..03ffc9a48 --- /dev/null +++ b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct CodexBarConfigMigratorTests { + @Test + func `legacy secret migration completion flag skips repeated scans`() throws { + let suite = "CodexBarConfigMigratorTests-skip-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let secrets = CountingLegacySecretStore() + let accountStore = CountingTokenAccountStore() + let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) + let configStore = testConfigStore(suiteName: suite) + + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + let firstSecretLoads = secrets.loadCount + let firstAccountLoads = accountStore.loadCount + #expect(firstSecretLoads > 0) + #expect(firstAccountLoads == 1) + #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) + + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + #expect(secrets.loadCount == firstSecretLoads) + #expect(accountStore.loadCount == firstAccountLoads) + } + + @Test + func `legacy migration completion waits for successful cleanup`() throws { + let suite = "CodexBarConfigMigratorTests-cleanup-failure-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let secrets = CountingLegacySecretStore(token: "legacy-token", throwOnStore: true) + let accountStore = CountingTokenAccountStore() + let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) + let configStore = testConfigStore(suiteName: suite) + + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + let firstSecretLoads = secrets.loadCount + #expect(firstSecretLoads > 0) + #expect(secrets.clearAttempts > 0) + #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false) + + secrets.throwOnStore = false + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + #expect(secrets.loadCount > firstSecretLoads) + #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) + } + + private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted" + + private static func legacyStores( + secrets: CountingLegacySecretStore, + accountStore: CountingTokenAccountStore) -> CodexBarConfigMigrator.LegacyStores + { + CodexBarConfigMigrator.LegacyStores( + zaiTokenStore: secrets, + syntheticTokenStore: secrets, + codexCookieStore: secrets, + claudeCookieStore: secrets, + cursorCookieStore: secrets, + opencodeCookieStore: secrets, + factoryCookieStore: secrets, + minimaxCookieStore: secrets, + minimaxAPITokenStore: secrets, + kimiTokenStore: secrets, + kimiK2TokenStore: secrets, + augmentCookieStore: secrets, + ampCookieStore: secrets, + copilotTokenStore: secrets, + tokenAccountStore: accountStore) + } +} + +private final class CountingLegacySecretStore: ZaiTokenStoring, SyntheticTokenStoring, CookieHeaderStoring, + MiniMaxCookieStoring, MiniMaxAPITokenStoring, KimiTokenStoring, KimiK2TokenStoring, CopilotTokenStoring, + @unchecked Sendable +{ + private let lock = NSLock() + private var token: String? + var throwOnStore: Bool + private(set) var loadCount = 0 + private(set) var clearAttempts = 0 + + init(token: String? = nil, throwOnStore: Bool = false) { + self.token = token + self.throwOnStore = throwOnStore + } + + func loadToken() throws -> String? { + self.lock.lock() + defer { self.lock.unlock() } + self.loadCount += 1 + return self.token + } + + func storeToken(_ token: String?) throws { + try self.store(token) + } + + func loadCookieHeader() throws -> String? { + self.lock.lock() + defer { self.lock.unlock() } + self.loadCount += 1 + return self.token + } + + func storeCookieHeader(_ header: String?) throws { + try self.store(header) + } + + private func store(_ value: String?) throws { + self.lock.lock() + defer { self.lock.unlock() } + self.clearAttempts += value == nil ? 1 : 0 + if self.throwOnStore { + throw TestStoreError.storeFailed + } + self.token = value + } +} + +private final class CountingTokenAccountStore: ProviderTokenAccountStoring, @unchecked Sendable { + private let lock = NSLock() + private(set) var loadCount = 0 + + func loadAccounts() throws -> [UsageProvider: ProviderTokenAccountData] { + self.lock.lock() + defer { self.lock.unlock() } + self.loadCount += 1 + return [:] + } + + func storeAccounts(_: [UsageProvider: ProviderTokenAccountData]) throws {} + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("codexbar-empty-accounts.json") + } +} + +private enum TestStoreError: Error { + case storeFailed +} diff --git a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift index f8ac1bed5..e6549c90a 100644 --- a/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexBaselineCharacterizationTests.swift @@ -8,13 +8,14 @@ struct CodexBaselineCharacterizationTests { runtime: ProviderRuntime, sourceMode: ProviderSourceMode, env: [String: String] = [:], - settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + settings: ProviderSettingsSnapshot? = nil, + includeCredits: Bool = false) -> ProviderFetchContext { let browserDetection = BrowserDetection(cacheTTL: 0) return ProviderFetchContext( runtime: runtime, sourceMode: sourceMode, - includeCredits: false, + includeCredits: includeCredits, webTimeout: 1, webDebugDumpHTML: false, verbose: false, @@ -41,10 +42,16 @@ struct CodexBaselineCharacterizationTests { runtime: ProviderRuntime, sourceMode: ProviderSourceMode, env: [String: String] = [:], - settings: ProviderSettingsSnapshot? = nil) async -> ProviderFetchOutcome + settings: ProviderSettingsSnapshot? = nil, + includeCredits: Bool = false) async -> ProviderFetchOutcome { let descriptor = ProviderDescriptorRegistry.descriptor(for: .codex) - let context = self.makeContext(runtime: runtime, sourceMode: sourceMode, env: env, settings: settings) + let context = self.makeContext( + runtime: runtime, + sourceMode: sourceMode, + env: env, + settings: settings, + includeCredits: includeCredits) return await descriptor.fetchPlan.fetchOutcome(context: context, provider: .codex) } @@ -52,8 +59,14 @@ struct CodexBaselineCharacterizationTests { let script = """ #!/usr/bin/python3 import json + import os import sys + counter = os.environ.get("CODEXBAR_STUB_COUNTER") + if counter: + with open(counter, "a") as f: + f.write("start\\n") + for line in sys.stdin: if not line.strip(): continue @@ -195,7 +208,7 @@ struct CodexBaselineCharacterizationTests { } @Test - func `app auto falls back from failing OAuth to successful CLI`() async throws { + func `app auto does not fall back from non auth failing OAuth`() async throws { let stubCLIPath = try self.makeStubCodexCLI() let oauthHome = try self.makeUnavailableOAuthHome() defer { try? FileManager.default.removeItem(at: oauthHome) } @@ -207,19 +220,57 @@ struct CodexBaselineCharacterizationTests { let outcome = await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env) - #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth", "codex.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [true, true]) + #expect(outcome.attempts.map(\.strategyID) == ["codex.oauth"]) + #expect(outcome.attempts.map(\.wasAvailable) == [true]) #expect(outcome.attempts[0].errorDescription?.isEmpty == false) - #expect(outcome.attempts[1].errorDescription == nil) + + switch outcome.result { + case .success: + Issue.record("Expected non-auth OAuth failure to stop before CLI fallback") + case let .failure(error as CodexOAuthFetchError): + switch error { + case .networkError: + break + default: + Issue.record("Expected network error, got \(error)") + } + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } + } + + @Test + func `Codex CLI strategy fetches usage and credits with one app-server process`() async throws { + let stubCLIPath = try self.makeStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + let counterURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-stub-counter-\(UUID().uuidString)", isDirectory: false) + defer { try? FileManager.default.removeItem(at: counterURL) } + + let env = [ + "CODEX_CLI_PATH": stubCLIPath, + "CODEXBAR_STUB_COUNTER": counterURL.path, + ] + + let outcome = await self.fetchOutcome( + runtime: .app, + sourceMode: .cli, + env: env, + includeCredits: true) switch outcome.result { case let .success(result): #expect(result.sourceLabel == "codex-cli") - #expect(result.usage.primary?.windowMinutes == 300) - #expect(result.usage.secondary?.windowMinutes == 10080) + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.credits?.remaining == 7) case let .failure(error): Issue.record("Unexpected failure: \(error)") } + + let count = (try? String(contentsOf: counterURL, encoding: .utf8))? + .split(whereSeparator: \.isNewline) + .count ?? 0 + #expect(count == 1) } @Test diff --git a/Tests/CodexBarTests/CodexCLILaunchGateTests.swift b/Tests/CodexBarTests/CodexCLILaunchGateTests.swift new file mode 100644 index 000000000..bb0babe19 --- /dev/null +++ b/Tests/CodexBarTests/CodexCLILaunchGateTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CodexCLILaunchGateTests { + @Test + func `background launch failures suppress repeated background launches until cooldown expires`() { + let gate = CodexCLILaunchGate.shared + gate.resetForTesting() + defer { gate.resetForTesting() } + let now = Date(timeIntervalSince1970: 100) + + let message = gate.recordLaunchFailure( + binary: "/opt/homebrew/bin/codex", + message: "\"codex\" was not opened because it contains malware.", + now: now) + + #expect(message?.contains("background refresh is paused") == true) + #expect(gate.backgroundSkipMessage( + binary: "/opt/homebrew/bin/codex", + now: now.addingTimeInterval(60), + interaction: .background) == message) + #expect(gate.backgroundSkipMessage( + binary: "/opt/homebrew/bin/codex", + now: now.addingTimeInterval(60), + interaction: .userInitiated) == nil) + #expect(gate.backgroundSkipMessage( + binary: "/opt/homebrew/bin/codex", + now: now.addingTimeInterval(CodexCLILaunchGate.cooldown + 1), + interaction: .background) == nil) + } + + @Test + func `PTY infrastructure failures do not suppress future Codex launches`() { + #expect(CodexCLILaunchGate.shouldThrottleLaunchFailure("openpty failed") == false) + #expect(CodexCLILaunchGate.shouldThrottleLaunchFailure("write to PTY failed") == false) + #expect(CodexCLILaunchGate.shouldThrottleLaunchFailure("The operation could not be completed") == true) + } +} diff --git a/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift b/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift index a50770489..417517870 100644 --- a/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift +++ b/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift @@ -112,6 +112,65 @@ struct CodexCLIWindowNormalizationTests { } } + @Test + func `maps plan only RPC limits into empty identified snapshot`() throws { + let snapshot = try UsageFetcher._mapCodexRPCLimitsForTesting( + primary: nil, + secondary: nil, + planType: "pro") + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.loginMethod(for: .codex) == "pro") + #expect(snapshot.rateLimitsUnavailable(for: .codex)) + } + + @Test + func `codex no rate limit error means limits unavailable without snapshot`() { + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: nil, + account: AccountInfo(email: "user@example.com", plan: nil), + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .unavailable) + } + + @Test + func `codex no rate limit error stays available without account context`() { + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: nil, + account: AccountInfo(email: nil, plan: nil), + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .available) + } + + @Test + func `codex windowed snapshot wins over stale no rate limit error`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity) + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: snapshot, + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .available) + } + @Test func `maps weekly only status snapshot into secondary`() throws { let status = CodexStatusSnapshot( diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index cb8ccb90d..36f3fe902 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -111,6 +111,68 @@ struct CodexManagedOpenAIWebRefreshTests { #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } + @Test + func `navigation timeout imports cookies and retries dashboard refresh`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-timeout-import-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() + store._test_openAIDashboardLoaderOverride = { _, _, _ in + 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: true, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await importTracker.waitUntilCalls(count: 1) + await blocker.waitUntilStarted(count: 2) + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + + #expect(await blocker.startedCount() == 2) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(store.lastOpenAIDashboardError == nil) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -224,6 +286,8 @@ struct CodexManagedOpenAIWebRefreshTests { @Test func `post import retry timeout exceeds normal retry timeout`() { + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: false) == 25) + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: true) == 25) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: false) == 8) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true) == 25) } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index 5bde40281..da934f23b 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -839,4 +839,43 @@ struct CodexManagedOpenAIWebTests { "Switch chatgpt.com account, then refresh OpenAI cookies.") #expect(store.openAIDashboard == nil) } + + @Test + func `managed codex refresh reports no matching web session without fake account`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebTests-no-matching-web-session") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "ratulsarna@gmail.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) + + store._test_openAIDashboardLoaderOverride = { _, _, _ in + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in + throw OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount(found: []) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + + #expect( + store.lastOpenAIDashboardError == + "No matching OpenAI web session found for ratulsarna@gmail.com. " + + "Sign in to chatgpt.com as ratulsarna@gmail.com, then refresh OpenAI cookies.") + #expect(store.openAIDashboard == nil) + } } diff --git a/Tests/CodexBarTests/CodexManagedRoutingTests.swift b/Tests/CodexBarTests/CodexManagedRoutingTests.swift index a0c642920..2a2e2fcec 100644 --- a/Tests/CodexBarTests/CodexManagedRoutingTests.swift +++ b/Tests/CodexBarTests/CodexManagedRoutingTests.swift @@ -37,6 +37,72 @@ struct CodexManagedRoutingTests { #expect(claudeEnv["CODEX_HOME"] == nil) } + @Test + func `provider registry scopes codex environment with source override without persisting selection`() throws { + let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-source-override-env") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/codex-managed-override-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-override-\(UUID().uuidString).json") + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [managedAccount])) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + + let env = ProviderRegistry.makeEnvironment( + base: ["CODEX_HOME": "/Users/example/.codex"], + provider: .codex, + settings: settings, + tokenOverride: nil, + codexActiveSourceOverride: .managedAccount(id: managedAccount.id)) + + #expect(env["CODEX_HOME"] == managedAccount.managedHomePath) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + func `provider registry builds codex snapshot with source override without persisting selection`() throws { + let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-source-override-snapshot") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/codex-managed-override-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-snapshot-override-\(UUID().uuidString).json") + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [managedAccount])) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + + let snapshot = ProviderRegistry.makeSettingsSnapshot( + settings: settings, + tokenOverride: nil, + codexActiveSourceOverride: .managedAccount(id: UUID())) + + #expect(snapshot.codex?.managedAccountTargetUnavailable == true) + #expect(settings.codexActiveSource == .liveSystem) + } + @Test func `provider registry preserves ambient live system home when active source is live system`() { let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-live-system-routing") @@ -110,12 +176,16 @@ struct CodexManagedRoutingTests { @Test func `provider registry prefers live system routing when managed and live share email`() { let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-same-email-prefers-live") - let managedHomePath = "/tmp/managed-remote-home" + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) let liveHomePath = "/tmp/system-remote-home" + defer { try? FileManager.default.removeItem(at: managedHome) } + let managedAccount = ManagedCodexAccount( id: UUID(), email: "person@example.com", - managedHomePath: managedHomePath, + managedHomePath: managedHome.path, createdAt: 1, updatedAt: 1, lastAuthenticatedAt: 1) @@ -140,7 +210,7 @@ struct CodexManagedRoutingTests { #expect(settings.codexResolvedActiveSource == .liveSystem) #expect(env["CODEX_HOME"] == liveHomePath) - #expect(env["CODEX_HOME"] != managedHomePath) + #expect(env["CODEX_HOME"] != managedHome.path) } @Test @@ -504,6 +574,50 @@ struct CodexManagedRoutingTests { #expect(account.plan == "pro") } + @Test + func `usage store builds codex fetch context with source override without persisting selection`() throws { + let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-usage-source-override") + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + defer { try? FileManager.default.removeItem(at: managedHome) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-usage-override-\(UUID().uuidString).json") + let managedStore = FileManagedCodexAccountStore(fileURL: storeURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [managedAccount])) + try self.writeCodexAuthFile(homeURL: managedHome, email: "override@example.com", plan: "pro") + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .liveSystem + defer { + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + let context = store.makeFetchContext( + provider: .codex, + override: nil, + codexActiveSourceOverride: .managedAccount(id: managedAccount.id)) + + let account = context.fetcher.loadAccountInfo() + #expect(account.email == "override@example.com") + #expect(account.plan == "pro") + #expect(settings.codexActiveSource == .liveSystem) + } + @Test func `usage store builds codex token account fetcher scoped to managed home`() throws { let settings = self.makeSettingsStore(suite: "CodexManagedRoutingTests-usage-store") diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index 45d52013d..2d3410e84 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -3,6 +3,22 @@ import Testing @testable import CodexBarCore struct CodexOAuthTests { + private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: true, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + @Test func `parses O auth credentials`() throws { let json = """ @@ -339,7 +355,7 @@ struct CodexOAuthTests { } @Test - func `auto mode falls back when primary window is malformed but weekly window survives`() throws { + func `auto mode keeps weekly window when primary window is malformed`() throws { let json = """ { "rate_limit": { @@ -363,12 +379,14 @@ struct CodexOAuthTests { accountId: nil, lastRefresh: Date()) - #expect(throws: UsageError.noRateLimitsFound) { - _ = try CodexOAuthFetchStrategy._mapResultForTesting( - Data(json.utf8), - credentials: creds, - sourceMode: .auto) - } + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary?.usedPercent == 43) + #expect(result.usage.secondary?.windowMinutes == 10080) } @Test @@ -442,7 +460,7 @@ struct CodexOAuthTests { } @Test - func `auto mode falls back when reversed session window is malformed in secondary`() throws { + func `auto mode keeps weekly window when reversed session window is malformed`() throws { let json = """ { "rate_limit": { @@ -466,12 +484,14 @@ struct CodexOAuthTests { accountId: nil, lastRefresh: Date()) - #expect(throws: UsageError.noRateLimitsFound) { - _ = try CodexOAuthFetchStrategy._mapResultForTesting( - Data(json.utf8), - credentials: creds, - sourceMode: .auto) - } + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary?.usedPercent == 43) + #expect(result.usage.secondary?.windowMinutes == 10080) } @Test @@ -573,7 +593,7 @@ struct CodexOAuthTests { } @Test - func `credits only O auth payload falls back in auto mode`() throws { + func `credits only O auth payload returns credits in auto mode`() throws { let json = """ { "rate_limit": { @@ -594,14 +614,76 @@ struct CodexOAuthTests { accountId: nil, lastRefresh: Date()) - #expect(throws: UsageError.noRateLimitsFound) { - _ = try CodexOAuthFetchStrategy._mapResultForTesting( - Data(json.utf8), - credentials: creds, - sourceMode: .auto) + let result = try CodexOAuthFetchStrategy._mapResultForTesting( + Data(json.utf8), + credentials: creds, + sourceMode: .auto) + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.credits?.remaining == 14.5) + #expect(result.sourceLabel == "oauth") + } + + @Test + func `auto mode only falls back from O auth on auth failures`() { + let strategy = CodexOAuthFetchStrategy() + let context = self.makeContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.notFound, context: context)) + #expect(strategy.shouldFallback(on: CodexOAuthCredentialsError.missingTokens, context: context)) + #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) + #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.revoked, context: context)) + #expect(strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.reused, context: context)) + + #expect(!strategy.shouldFallback(on: UsageError.noRateLimitsFound, context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthCredentialsError.decodeFailed("bad json"), context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.invalidResponse, context: context)) + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.serverError(500, "offline"), context: context)) + #expect(!strategy.shouldFallback( + on: CodexOAuthFetchError.networkError(URLError(.notConnectedToInternet)), + context: context)) + #expect(!strategy.shouldFallback( + on: CodexTokenRefresher.RefreshError.networkError(URLError(.timedOut)), + context: context)) + } + + @Test + func `non 401 invalid grant refresh failure is treated as revoked`() { + let data = Data(#"{"error":"invalid_grant"}"#.utf8) + let error = CodexTokenRefresher._refreshFailureErrorForTesting(statusCode: 400, data: data) + + switch error { + case .revoked: + break + default: + Issue.record("Expected invalid_grant to be treated as revoked") + } + } + + @Test + func `non auth refresh failure remains invalid response`() { + let data = Data(#"{"error":"invalid_request"}"#.utf8) + let error = CodexTokenRefresher._refreshFailureErrorForTesting(statusCode: 400, data: data) + + switch error { + case let .invalidResponse(message): + #expect(message == "Status 400") + default: + Issue.record("Expected invalid_request to remain an invalid response") } } + @Test + func `explicit O auth mode never falls back to CLI`() { + let strategy = CodexOAuthFetchStrategy() + let context = self.makeContext(sourceMode: .oauth) + + #expect(!strategy.shouldFallback(on: CodexOAuthFetchError.unauthorized, context: context)) + #expect(!strategy.shouldFallback(on: CodexTokenRefresher.RefreshError.expired, context: context)) + } + @Test func `resolves chat GPT usage URL from config`() { let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n" diff --git a/Tests/CodexBarTests/CodexPlanFormattingTests.swift b/Tests/CodexBarTests/CodexPlanFormattingTests.swift index afc7a538b..ca82b8f00 100644 --- a/Tests/CodexBarTests/CodexPlanFormattingTests.swift +++ b/Tests/CodexBarTests/CodexPlanFormattingTests.swift @@ -4,11 +4,15 @@ import Testing struct CodexPlanFormattingTests { @Test - func `maps prolite aliases to Pro Lite`() { - #expect(CodexPlanFormatting.displayName("prolite") == "Pro Lite") - #expect(CodexPlanFormatting.displayName("pro_lite") == "Pro Lite") - #expect(CodexPlanFormatting.displayName("pro-lite") == "Pro Lite") - #expect(CodexPlanFormatting.displayName("pro lite") == "Pro Lite") + func `maps Codex pro plans to usage multiplier names`() { + #expect(CodexPlanFormatting.displayName("pro") == "Pro 20x") + #expect(CodexPlanFormatting.displayName("Pro") == "Pro 20x") + #expect(CodexPlanFormatting.displayName("Codex Pro") == "Pro 20x") + #expect(CodexPlanFormatting.displayName("prolite") == "Pro 5x") + #expect(CodexPlanFormatting.displayName("pro_lite") == "Pro 5x") + #expect(CodexPlanFormatting.displayName("pro-lite") == "Pro 5x") + #expect(CodexPlanFormatting.displayName("Pro Lite") == "Pro 5x") + #expect(CodexPlanFormatting.displayName("Codex Pro Lite") == "Pro 5x") } @Test @@ -30,8 +34,7 @@ struct CodexPlanFormattingTests { } @Test - func `preserves already readable plan text`() { + func `preserves unrelated already readable plan text`() { #expect(CodexPlanFormatting.displayName("Enterprise") == "Enterprise") - #expect(CodexPlanFormatting.displayName("Pro Lite") == "Pro Lite") } } diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 24345c25f..670c44cd9 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -96,7 +96,7 @@ struct CodexPresentationCharacterizationTests { } @Test - func `Codex menu humanizes prolite plan from snapshot identity`() { + func `Codex menu maps prolite plan to multiplier display name`() { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-prolite") settings.statusChecksEnabled = false @@ -127,7 +127,8 @@ struct CodexPresentationCharacterizationTests { includeContextualActions: false) let lines = self.textLines(from: descriptor) - #expect(lines.contains("Plan: Pro Lite")) + #expect(lines.contains("Plan: Pro 5x")) + #expect(!lines.contains("Plan: Pro Lite")) #expect(!lines.contains("Plan: Prolite")) } diff --git a/Tests/CodexBarTests/CodexSystemPromotionUITests.swift b/Tests/CodexBarTests/CodexSystemPromotionUITests.swift index 3f10eb26e..0f780686d 100644 --- a/Tests/CodexBarTests/CodexSystemPromotionUITests.swift +++ b/Tests/CodexBarTests/CodexSystemPromotionUITests.swift @@ -119,4 +119,35 @@ struct CodexSystemPromotionUITests { #expect(submenu.2[1].isEnabled) #expect(submenu.2[1].action == .requestCodexSystemPromotion(managedAccountID)) } + + @Test + func `codex menu descriptor hides single live system account submenu`() throws { + let container = try CodexAccountPromotionTestContainer( + suiteName: "CodexSystemPromotionUITests-menu-single-live") + defer { container.tearDown() } + + container.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: container.liveHomeURL.path, + observedAt: Date()) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: container.usageStore, + settings: container.settings, + account: UsageFetcher().loadAccountInfo(), + managedCodexAccountCoordinator: ManagedCodexAccountCoordinator(), + codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator( + service: container.makeService()), + updateReady: false) + + let hasSystemAccountSubmenu = descriptor.sections + .flatMap(\.entries) + .contains { entry in + guard case let .submenu(title, _, _) = entry else { return false } + return title == "System Account" + } + + #expect(!hasSystemAccountSubmenu) + } } diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index 4cd2442d9..4e33617d8 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -24,6 +24,20 @@ struct CodexUsageFetcherFallbackTests { #expect(credits?.remaining == 0) } + @Test + func `CLI credits recover from RPC error body when usage windows are unusable`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.creditsOnlyDecodeMismatchBodyMessage) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let credits = try await fetcher.loadLatestCredits() + + #expect(credits.remaining == 14.5) + await #expect(throws: UsageError.noRateLimitsFound) { + _ = try await fetcher.loadLatestUsage() + } + } + @Test func `CLI usage does not partially recover malformed RPC body without session lane`() { let snapshot = UsageFetcher._recoverCodexRPCUsageFromErrorForTesting( @@ -33,48 +47,143 @@ struct CodexUsageFetcherFallbackTests { } @Test - func `CLI usage falls back from RPC decode mismatch to TTY status`() async throws { - let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) + func `CLI usage recovers from RPC body without TTY fallback`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchBodyMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } - let fetcher = UsageFetcher( - environment: ["CODEX_CLI_PATH": stubCLIPath], - codexStatusFetcher: Self.stubTTYStatus) + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) let snapshot = try await fetcher.loadLatestUsage() - #expect(snapshot.primary?.usedPercent == 12) + #expect(snapshot.primary?.usedPercent == 4) #expect(snapshot.primary?.windowMinutes == 300) - #expect(snapshot.secondary?.usedPercent == 25) + #expect(snapshot.secondary?.usedPercent == 19) #expect(snapshot.secondary?.windowMinutes == 10080) } @Test - func `CLI credits fall back from RPC decode mismatch to TTY status`() async throws { - let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchMessage) + func `CLI credits recover from RPC body without TTY fallback`() async throws { + let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.decodeMismatchBodyMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } - let fetcher = UsageFetcher( - environment: ["CODEX_CLI_PATH": stubCLIPath], - codexStatusFetcher: Self.stubTTYStatus) + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) let credits = try await fetcher.loadLatestCredits() - #expect(credits.remaining == 42) + #expect(credits.remaining == 0) + } + + @Test + func `CLI credits load from RPC response without usage windows`() async throws { + let stubCLIPath = try self.makeCreditsOnlyStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let credits = try await fetcher.loadLatestCredits() + + #expect(credits.remaining == 21) + await #expect(throws: UsageError.noRateLimitsFound) { + _ = try await fetcher.loadLatestUsage() + } + } + + @Test + func `CLI usage loads plan only RPC response as unavailable limits`() async throws { + let stubCLIPath = try self.makePlanOnlyStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestUsage() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.accountEmail(for: .codex) == "stub@example.com") + #expect(snapshot.loginMethod(for: .codex) == "pro") + #expect(snapshot.rateLimitsUnavailable(for: .codex)) + } + + @Test + func `CLI plan and credits response without usage windows keeps unavailable limits`() async throws { + let stubCLIPath = try self.makePlanOnlyStubCodexCLI(includeCredits: true) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestCLIAccountSnapshot() + + #expect(snapshot.usage?.primary == nil) + #expect(snapshot.usage?.secondary == nil) + #expect(snapshot.usage?.rateLimitsUnavailable(for: .codex) == true) + #expect(snapshot.credits?.remaining == 21) } @Test - func `CLI usage falls back to TTY when RPC body recovery misses session lane`() async throws { + func `CLI usage fails when RPC body recovery misses session lane`() async throws { let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.partialDecodeBodyMessage) defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + + do { + _ = try await fetcher.loadLatestUsage() + Issue.record("Expected RPC failure without PTY fallback") + } catch { + #expect(error.localizedDescription.contains("Codex connection failed")) + } + } + + @Test + func `hung CLI RPC rate limits request times out within budget`() async throws { + let stubCLIPath = try self.makeHungRateLimitsStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + let fetcher = UsageFetcher( environment: ["CODEX_CLI_PATH": stubCLIPath], - codexStatusFetcher: Self.stubTTYStatus) - let snapshot = try await fetcher.loadLatestUsage() + initializeTimeoutSeconds: 2.0, + requestTimeoutSeconds: 0.2) - #expect(snapshot.primary?.usedPercent == 12) - #expect(snapshot.primary?.windowMinutes == 300) - #expect(snapshot.secondary?.usedPercent == 25) - #expect(snapshot.secondary?.windowMinutes == 10080) + let started = Date() + do { + _ = try await fetcher.loadLatestUsage() + Issue.record("Expected hung Codex RPC usage request to time out") + } catch let error as RPCWireError { + guard case let .timeout(method) = error else { + Issue.record("Expected RPC timeout, got \(error)") + return + } + #expect(method == "account/rateLimits/read") + } catch { + Issue.record("Expected RPCWireError.timeout, got \(type(of: error)): \(error)") + } + + let elapsed = Date().timeIntervalSince(started) + #expect(elapsed < 3.0, "Hung RPC request must fail fast, took \(elapsed)s") + } + + @Test + func `repeated hung CLI RPC requests stay bounded`() async throws { + let stubCLIPath = try self.makeHungRateLimitsStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher( + environment: ["CODEX_CLI_PATH": stubCLIPath], + initializeTimeoutSeconds: 2.0, + requestTimeoutSeconds: 0.2) + + for attempt in 1...2 { + let started = Date() + do { + _ = try await fetcher.loadLatestCredits() + Issue.record("Expected hung Codex RPC credits request \(attempt) to time out") + } catch let error as RPCWireError { + guard case .timeout = error else { + Issue.record("Expected RPC timeout on attempt \(attempt), got \(error)") + return + } + } catch { + Issue.record("Expected RPCWireError.timeout on attempt \(attempt), got \(type(of: error)): \(error)") + } + + let elapsed = Date().timeIntervalSince(started) + #expect(elapsed < 3.0, "Hung RPC request \(attempt) must fail fast, took \(elapsed)s") + } } private static let decodeMismatchBodyMessage = """ @@ -110,11 +219,6 @@ struct CodexUsageFetcherFallbackTests { } """ - private static let decodeMismatchMessage = """ - failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: - unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro` - """ - private static let partialDecodeBodyMessage = """ failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`; @@ -139,20 +243,29 @@ struct CodexUsageFetcherFallbackTests { } """ - private static func stubTTYStatus( - environment _: [String: String], - keepCLISessionsAlive _: Bool) async throws -> CodexStatusSnapshot - { - CodexStatusSnapshot( - credits: 42, - fiveHourPercentLeft: 88, - weeklyPercentLeft: 75, - fiveHourResetDescription: nil, - weeklyResetDescription: nil, - fiveHourResetsAt: nil, - weeklyResetsAt: nil, - rawText: "Credits: 42 credits\n5h limit: [#####] 88% left\nWeekly limit: [##] 75% left\n") + private static let creditsOnlyDecodeMismatchBodyMessage = """ + failed to fetch codex rate limits: Decode error for https://chatgpt.com/backend-api/wham/usage: + unknown variant `prolite`, expected one of `guest`, `free`, `go`, `plus`, `pro`; + content-type=application/json; body={ + "email": "prolite-test@example.com", + "plan_type": "prolite", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": "oops", + "limit_window_seconds": 18000, + "reset_at": 1776216359 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "overage_limit_reached": false, + "balance": "14.5" + } } + """ private func makeDecodeMismatchStubCodexCLI( message: String = Self.decodeMismatchBodyMessage) @@ -200,8 +313,8 @@ struct CodexUsageFetcherFallbackTests { print(json.dumps(payload), flush=True) else: - sys.stdout.write("Credits: 42 credits\\n5h limit: [#####] 88% left\\nWeekly limit: [##] 75% left\\n") - sys.stdout.flush() + sys.stderr.write("unexpected non app-server Codex invocation\\n") + sys.exit(92) """ let url = FileManager.default.temporaryDirectory .appendingPathComponent("codex-fallback-stub-\(UUID().uuidString)", isDirectory: false) @@ -209,4 +322,160 @@ struct CodexUsageFetcherFallbackTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) return url.path } + + private func makePlanOnlyStubCodexCLI(includeCredits: Bool = false) throws -> String { + let creditsPayload = includeCredits + ? [ + ",", + " \"credits\": {", + " \"hasCredits\": True,", + " \"unlimited\": False,", + " \"balance\": \"21\"", + " }", + ].joined(separator: "\n") + : "" + let script = """ + #!/usr/bin/python3 + import json + import sys + + args = sys.argv[1:] + if "app-server" in args: + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + if method == "initialized": + continue + + identifier = message.get("id") + if method == "initialize": + payload = {"id": identifier, "result": {}} + elif method == "account/rateLimits/read": + payload = { + "id": identifier, + "result": { + "rateLimits": { + "planType": "pro" + \(creditsPayload) + } + } + } + elif method == "account/read": + payload = { + "id": identifier, + "result": { + "account": { + "type": "chatgpt", + "email": "stub@example.com", + "planType": "pro" + }, + "requiresOpenaiAuth": False + } + } + else: + payload = {"id": identifier, "result": {}} + + print(json.dumps(payload), flush=True) + else: + sys.stderr.write("unexpected non app-server Codex invocation\\n") + sys.exit(92) + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-plan-only-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func makeCreditsOnlyStubCodexCLI() throws -> String { + let script = """ + #!/usr/bin/python3 + import json + import sys + + args = sys.argv[1:] + if "app-server" in args: + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + if method == "initialized": + continue + + identifier = message.get("id") + if method == "initialize": + payload = {"id": identifier, "result": {}} + elif method == "account/rateLimits/read": + payload = { + "id": identifier, + "result": { + "rateLimits": { + "credits": { + "hasCredits": True, + "unlimited": False, + "balance": "21" + } + } + } + } + elif method == "account/read": + payload = { + "id": identifier, + "result": { + "account": { + "type": "chatgpt", + "email": "stub@example.com", + "planType": "pro" + }, + "requiresOpenaiAuth": False + } + } + else: + payload = {"id": identifier, "result": {}} + + print(json.dumps(payload), flush=True) + else: + sys.stderr.write("unexpected non app-server Codex invocation\\n") + sys.exit(92) + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-credits-only-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func makeHungRateLimitsStubCodexCLI() throws -> String { + let script = """ + #!/bin/sh + case " $* " in + *" app-server "*) ;; + *) printf '%s\\n' "unexpected non app-server Codex invocation" >&2; exit 92 ;; + esac + + while IFS= read -r line; do + case "$line" in + *'"method":"initialized"'*|*'"method": "initialized"'*) + ;; + *'"method":"initialize"'*|*'"method": "initialize"'*) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + *'"method":"account/rateLimits/read"'*|*'"method": "account/rateLimits/read"'*) + sleep 30 + ;; + *) + printf '%s\\n' '{"id":1,"result":{}}' + ;; + esac + done + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-hung-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } } diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index be6f954e2..d265dedf4 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -74,6 +74,28 @@ struct CodexUserFacingErrorTests { "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again.") } + @Test + func `open A I web timeout becomes retry guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-timeout") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1001.)" + + #expect( + store.userFacingLastOpenAIDashboardError == + "OpenAI web refresh timed out. Refresh OpenAI cookies and try again.") + } + + @Test + func `open A I web network error becomes connection guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-network") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1004.)" + let expected = [ + "OpenAI web refresh hit a network error.", + "Check your connection, then refresh OpenAI cookies and try again.", + ].joined(separator: " ") + + #expect(store.userFacingLastOpenAIDashboardError == expected) + } + @Test func `non codex providers keep raw errors`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-non-codex") diff --git a/Tests/CodexBarTests/CommandCodeProviderTests.swift b/Tests/CodexBarTests/CommandCodeProviderTests.swift new file mode 100644 index 000000000..5273b244c --- /dev/null +++ b/Tests/CodexBarTests/CommandCodeProviderTests.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct CommandCodeProviderTests { + @Test + func `descriptor metadata is correct`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .commandcode) + + #expect(descriptor.metadata.displayName == "Command Code") + #expect(descriptor.metadata.dashboardURL == "https://commandcode.ai/studio") + #expect(descriptor.metadata.subscriptionDashboardURL == "https://commandcode.ai/sixhobbits/settings/billing") + #expect(descriptor.metadata.cliName == "commandcode") + #expect(descriptor.branding.iconResourceName == "ProviderIcon-commandcode") + #expect(descriptor.branding.iconStyle == .commandcode) + } + + @MainActor + @Test + func `implementation is registered`() { + #expect(ProviderCatalog.implementation(for: .commandcode) != nil) + } +} diff --git a/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift new file mode 100644 index 000000000..f8425f97e --- /dev/null +++ b/Tests/CodexBarTests/CommandCodeUsageFetcherTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Tests for `CommandCodeUsageFetcher` parsers and the cookie/snapshot derivation, +/// using real responses captured from api.commandcode.ai for an active "individual-go" plan. +struct CommandCodeUsageFetcherTests { + private static let creditsJSON = """ + {"credits":{"belowThreshold":false,"creditThreshold":0,"monthlyCredits":8.7784,\ + "purchasedCredits":0,"premiumMonthlyCredits":0,"opensourceMonthlyCredits":8.7784}} + """ + + private static let subscriptionJSON = """ + {"success":true,"data":{"id":"sub_1TTzt3DSZgxV3MJKG4ClCWpn","status":"active",\ + "userId":"915e93a7-a1f9-4c97-a3f0-20a85fcb3a45","orgId":null,\ + "createdAt":"2026-05-06T07:28:50.000Z","priceId":"price_1TMD8zDSZgxV3MJKxOZMVZrP",\ + "metadata":{"commandCode":"true","commandCodeUserId":"915e93a7-a1f9-4c97-a3f0-20a85fcb3a45"},\ + "quantity":1,"cancelAtPeriodEnd":false,\ + "currentPeriodStart":"2026-05-06T07:28:50.000Z","currentPeriodEnd":"2026-06-06T07:28:50.000Z",\ + "endedAt":null,"cancelAt":null,"canceledAt":null,"planId":"individual-go"}} + """ + + @Test + func `parses credits payload`() throws { + let data = try #require(Self.creditsJSON.data(using: .utf8)) + let payload = try CommandCodeUsageFetcher.parseCredits(data: data) + #expect(payload.monthlyCredits == 8.7784) + #expect(payload.purchasedCredits == 0) + #expect(payload.premiumMonthlyCredits == 0) + #expect(payload.opensourceMonthlyCredits == 8.7784) + } + + @Test + func `parses subscription payload`() throws { + let data = try #require(Self.subscriptionJSON.data(using: .utf8)) + let payload = try #require(try CommandCodeUsageFetcher.parseSubscription(data: data)) + #expect(payload.planID == "individual-go") + #expect(payload.status == "active") + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedEnd = isoFormatter.date(from: "2026-06-06T07:28:50.000Z") + #expect(payload.currentPeriodEnd == expectedEnd) + } + + @Test + func `subscription on free tier returns nil`() throws { + let data = Data(#"{"success":true,"data":null}"#.utf8) + let payload = try CommandCodeUsageFetcher.parseSubscription(data: data) + #expect(payload == nil) + } + + @Test + func `snapshot derives used and total from plan catalog`() throws { + let plan = try #require(CommandCodePlanCatalog.plan(forID: "individual-go")) + let snapshot = CommandCodeUsageSnapshot( + monthlyCreditsRemaining: 8.7784, + purchasedCredits: 0, + premiumMonthlyCredits: 0, + opensourceMonthlyCredits: 8.7784, + plan: plan, + billingPeriodEnd: Date(timeIntervalSince1970: 1_780_000_000), + subscriptionStatus: "active", + updatedAt: Date(timeIntervalSince1970: 0)) + #expect(snapshot.monthlyCreditsTotal == 10) + #expect(abs((snapshot.monthlyCreditsUsed ?? -1) - 1.2216) < 0.0001) + + let usage = snapshot.toUsageSnapshot() + let primary = try #require(usage.primary) + #expect(abs(primary.usedPercent - 12.216) < 0.001) + #expect(primary.resetsAt == Date(timeIntervalSince1970: 1_780_000_000)) + #expect(usage.identity?.loginMethod == "Go · $1.22 of $10.00") + } + + @Test + func `plan catalog covers known plans`() { + #expect(CommandCodePlanCatalog.plan(forID: "individual-go")?.monthlyCreditsUSD == 10) + #expect(CommandCodePlanCatalog.plan(forID: "individual-pro")?.monthlyCreditsUSD == 30) + #expect(CommandCodePlanCatalog.plan(forID: "individual-max")?.monthlyCreditsUSD == 150) + #expect(CommandCodePlanCatalog.plan(forID: "individual-ultra")?.monthlyCreditsUSD == 300) + #expect(CommandCodePlanCatalog.plan(forID: "unknown") == nil) + } + + @Test + func `cookie header extracts secure session cookie`() throws { + let raw = "_ga=GA1.2.123; __Secure-better-auth.session_token=abc123; foo=bar" + let override = try #require(CommandCodeCookieHeader.override(from: raw)) + #expect(override.name == "__Secure-better-auth.session_token") + #expect(override.token == "abc123") + #expect(override.headerValue == "__Secure-better-auth.session_token=abc123") + } + + @Test + func `cookie header accepts non-secure variant`() throws { + let raw = "better-auth.session_token=plain-token" + let override = try #require(CommandCodeCookieHeader.override(from: raw)) + #expect(override.name == "better-auth.session_token") + #expect(override.token == "plain-token") + } + + @Test + func `cookie header accepts bare token and uses secure name`() throws { + let override = try #require(CommandCodeCookieHeader.override(from: "bare-value")) + #expect(override.name == "__Secure-better-auth.session_token") + #expect(override.token == "bare-value") + } + + @Test + func `cookie header rejects empty input`() { + #expect(CommandCodeCookieHeader.override(from: nil) == nil) + #expect(CommandCodeCookieHeader.override(from: "") == nil) + #expect(CommandCodeCookieHeader.override(from: " ") == nil) + } +} diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 8e9f45554..e0c0f449f 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -58,4 +58,29 @@ struct ConfigValidationTests { let issues = CodexBarConfigValidator.validate(config) #expect(!issues.contains(where: { $0.provider == .kilo && $0.field == "extrasEnabled" })) } + + @Test + func `allows deepgram project workspace ID`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .deepgram, workspaceID: "project-123")) + let issues = CodexBarConfigValidator.validate(config) + #expect(!issues.contains(where: { $0.provider == .deepgram && $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" })) + } + + @Test + func `config store default url honors environment override`() { + let url = CodexBarConfigStore.defaultURL(environment: [ + CodexBarConfigStore.pathEnvironmentKey: "~/tmp/codexbar-test-config.json", + ]) + + #expect(url.path.hasSuffix("/tmp/codexbar-test-config.json")) + } } diff --git a/Tests/CodexBarTests/CookieHeaderCacheTests.swift b/Tests/CodexBarTests/CookieHeaderCacheTests.swift index de1247694..d5ba8856d 100644 --- a/Tests/CodexBarTests/CookieHeaderCacheTests.swift +++ b/Tests/CodexBarTests/CookieHeaderCacheTests.swift @@ -170,4 +170,64 @@ struct CookieHeaderCacheTests { #expect(Bool(false), "Expected invalid cookie cache to be cleared") } } + + @Test + func `clear all scopes removes global scoped invalid and legacy cookie entries`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase) + defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) } + + let provider: UsageProvider = .codex + let accountID = UUID() + CookieHeaderCache.store(provider: provider, cookieHeader: "auth=global", sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: provider, + scope: .managedAccount(accountID), + cookieHeader: "auth=scoped", + sourceLabel: "Chrome") + KeychainCacheStore.store( + key: .cookie(provider: provider, scopeIdentifier: "managed-store-unreadable"), + entry: WrongEntry(value: "invalid")) + CookieHeaderCache.store( + CookieHeaderCache.Entry( + cookieHeader: "auth=legacy", + storedAt: Date(timeIntervalSince1970: 0), + sourceLabel: "Legacy"), + to: CookieHeaderCache.legacyURLForTesting(provider: provider)) + + let cleared = CookieHeaderCache.clearAllScopes(provider: provider) + + #expect(cleared == 4) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider)) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider, scope: .managedAccount(accountID))) + #expect(!CookieHeaderCache.hasKeychainEntryForTesting(provider: provider, scope: .managedStoreUnreadable)) + #expect(!CookieHeaderCache.hasLegacyEntryForTesting(provider: provider)) + } + + @Test + func `clear all removes every provider cookie key without decoding entries`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + KeychainCacheStore.withServiceOverrideForTesting("cookie-clear-all-\(UUID().uuidString)") { + CookieHeaderCache.store(provider: .claude, cookieHeader: "auth=claude", sourceLabel: "Chrome") + CookieHeaderCache.store( + provider: .codex, + scope: .managedAccount(UUID()), + cookieHeader: "auth=codex", + sourceLabel: "Chrome") + KeychainCacheStore.store( + key: .cookie(provider: .cursor), + entry: WrongEntry(value: "invalid")) + + let cleared = CookieHeaderCache.clearAll() + + #expect(cleared >= 3) + #expect(KeychainCacheStore.keys(category: "cookie").isEmpty) + } + } } diff --git a/Tests/CodexBarTests/CopilotDeviceFlowTests.swift b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift index 7e0b1f20e..a79ac02ff 100644 --- a/Tests/CodexBarTests/CopilotDeviceFlowTests.swift +++ b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift @@ -39,4 +39,53 @@ struct CopilotDeviceFlowTests { #expect(response.verificationURLToOpen == "https://github.com/login/device") } + + @Test + func `device flow uses github by default`() throws { + let flow = CopilotDeviceFlow() + let deviceCodeURL = try #require(flow.deviceCodeURL) + let accessTokenURL = try #require(flow.accessTokenURL) + + #expect(deviceCodeURL.absoluteString == "https://github.com/login/device/code") + #expect(accessTokenURL.absoluteString == "https://github.com/login/oauth/access_token") + } + + @Test + func `device flow uses enterprise host`() throws { + let flow = CopilotDeviceFlow(enterpriseHost: "https://octocorp.ghe.com/login") + let deviceCodeURL = try #require(flow.deviceCodeURL) + let accessTokenURL = try #require(flow.accessTokenURL) + + #expect(deviceCodeURL.absoluteString == "https://octocorp.ghe.com/login/device/code") + #expect(accessTokenURL.absoluteString == "https://octocorp.ghe.com/login/oauth/access_token") + } + + @Test + func `device flow rejects invalid enterprise host without crashing`() { + let flow = CopilotDeviceFlow(enterpriseHost: "foo bar") + + #expect(flow.deviceCodeURL == nil) + #expect(flow.accessTokenURL == nil) + } + + @Test + func `device flow preserves enterprise host port`() throws { + let flow = CopilotDeviceFlow(enterpriseHost: "https://octocorp.ghe.com:8443/login") + let deviceCodeURL = try #require(flow.deviceCodeURL) + let accessTokenURL = try #require(flow.accessTokenURL) + + #expect(deviceCodeURL.absoluteString == "https://octocorp.ghe.com:8443/login/device/code") + #expect(accessTokenURL.absoluteString == "https://octocorp.ghe.com:8443/login/oauth/access_token") + } + + @Test + func `usage url uses enterprise api host`() throws { + let defaultURL = try #require(CopilotUsageFetcher.usageURL(enterpriseHost: nil)) + let enterpriseURL = try #require(CopilotUsageFetcher.usageURL(enterpriseHost: "octocorp.ghe.com")) + let enterprisePortURL = try #require(CopilotUsageFetcher.usageURL(enterpriseHost: "octocorp.ghe.com:8443")) + + #expect(defaultURL.absoluteString == "https://api.github.com/copilot_internal/user") + #expect(enterpriseURL.absoluteString == "https://api.octocorp.ghe.com/copilot_internal/user") + #expect(enterprisePortURL.absoluteString == "https://api.octocorp.ghe.com:8443/copilot_internal/user") + } } diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift new file mode 100644 index 000000000..0dc6ae673 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -0,0 +1,409 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// MARK: - Catalog + +@Test +func `copilot catalog entry exists`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + #expect(support != nil) + #expect(support?.requiresManualCookieSource == false) + #expect(support?.cookieName == nil) +} + +@Test +func `copilot catalog entry uses environment injection`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + guard let support else { + Issue.record("Copilot catalog entry missing") + return + } + if case let .environment(key) = support.injection { + #expect(key == "COPILOT_API_TOKEN") + } else { + Issue.record("Expected .environment injection, got cookieHeader") + } +} + +@Test +func `copilot env override uses correct key`() { + let override = TokenAccountSupportCatalog.envOverride(for: .copilot, token: "gh_abc") + #expect(override == ["COPILOT_API_TOKEN": "gh_abc"]) +} + +// MARK: - Username Fetch (parsing only) + +@Test +func `GitHub user response parses stable id and login`() throws { + let json = #"{"login": "testuser", "id": 123, "name": "Test User"}"# + let user = try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + #expect(user.id == 123) + #expect(user.login == "testuser") +} + +@Test +func `GitHub user response requires stable id`() throws { + let json = #"{"login": "minimaluser"}"# + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + } +} + +// MARK: - API Key Fallback + +@MainActor +struct CopilotAPIKeyFallbackTests { + @Test + func `ensure loader preserves config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-loader") + settings.copilotAPIToken = "gh_token_123" + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.copilotAPIToken == "gh_token_123") + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + } + + @Test + func `token accounts clear legacy config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-with-accounts") + settings.copilotAPIToken = "gh_token_old" + settings.addTokenAccount(provider: .copilot, label: "existing", token: "gh_token_existing") + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.tokenAccounts(for: .copilot).count == 1) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "gh_token_existing") + #expect(settings.tokenAccounts(for: .copilot).first?.label == "existing") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + 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()) + } +} + +// MARK: - Environment Precedence + +@MainActor +struct CopilotEnvironmentPrecedenceTests { + @Test + func `token account overrides config API key`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-env-override") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + let override = TokenAccountOverride(provider: .copilot, account: account) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: override) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: override) + + #expect(env["COPILOT_API_TOKEN"] == "new_account_token") + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `selected token account is included in copilot settings snapshot`() { + let settings = Self.makeSettingsStore(suite: "copilot-settings-snapshot-account") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `config API key used when no token accounts`() { + let settings = Self.makeSettingsStore(suite: "copilot-env-config-only") + settings.copilotAPIToken = "config_token" + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: nil) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(env["COPILOT_API_TOKEN"] == "config_token") + #expect(snapshot.copilot?.apiToken == "config_token") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + 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()) + } +} + +// MARK: - External Identifier Dedup + +@MainActor +struct CopilotExternalIdentifierTests { + @Test + func `addTokenAccount persists external identifier`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-add") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + + let account = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(account.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount preserves identifier when not provided`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-preserve") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + let original = try #require(settings.tokenAccounts(for: .copilot).first) + + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "octocat (Business)", + token: "gh_token_2") + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == original.id) + #expect(updated.token == "gh_token_2") + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount writes identifier back for legacy accounts`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-backfill") + // Legacy account: no externalIdentifier (pre-feature). + settings.addTokenAccount(provider: .copilot, label: "octocat (Pro)", token: "gh_legacy") + let legacy = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(legacy.externalIdentifier == nil) + + settings.updateTokenAccount( + provider: .copilot, + accountID: legacy.id, + label: "octocat (Pro)", + token: "gh_refreshed", + externalIdentifier: .some("octocat")) + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == legacy.id) + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `legacy Account N account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Account 1", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "octocat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `user renamed legacy account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Work GitHub", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "OctoCat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `stable external identifier match is preferred`() async { + let identified = Self.makeAccount( + label: "Personal", + token: "identified", + externalIdentifier: "github:user:123") + let legacy = Self.makeAccount(label: "octocat", token: "legacy", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy, identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + } + + @Test + func `legacy login external identifier still matches and can be backfilled`() async { + let identified = Self.makeAccount(label: "Personal", token: "identified", externalIdentifier: "OctoCat") + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when legacy externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + #expect(CopilotLoginFlow.externalIdentifier(for: Self.identity(id: 123, login: "octocat")) == "github:user:123") + } + + @Test + func `decoding legacy token account JSON yields nil identifier`() throws { + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "label": "octocat", + "token": "gh_legacy", + "addedAt": 1700000000.0 + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + #expect(account.label == "octocat") + #expect(account.externalIdentifier == nil) + #expect(account.lastUsed == nil) + } + + private nonisolated static func identity(id: Int64, login: String) -> CopilotUsageFetcher.GitHubUserIdentity { + CopilotUsageFetcher.GitHubUserIdentity(id: id, login: login) + } + + private static func makeAccount( + label: String, + token: String, + externalIdentifier: String?) -> ProviderTokenAccount + { + ProviderTokenAccount( + id: UUID(), + label: label, + token: token, + addedAt: 1_700_000_000, + lastUsed: nil, + externalIdentifier: externalIdentifier) + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + 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()) + } +} + +// MARK: - Token Account Snapshot Error Messages + +@MainActor +struct TokenAccountSnapshotErrorMessageTests { + @Test + func `cancellation is suppressed for global error path`() { + let store = Self.makeUsageStore() + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(URLError(.cancelled)) == nil) + } + + @Test + func `cancellation-like localized errors are suppressed`() { + let store = Self.makeUsageStore() + struct Cancelled: LocalizedError { + var errorDescription: String? { + "cancelled" + } + } + #expect(store.tokenAccountErrorMessage(Cancelled()) == nil) + } + + @Test + func `non-cancellation error preserves localized message`() { + let store = Self.makeUsageStore() + struct Boom: LocalizedError { + var errorDescription: String? { + "kaboom" + } + } + #expect(store.tokenAccountSnapshotErrorMessage(Boom()) == "kaboom") + #expect(store.tokenAccountErrorMessage(Boom()) == "kaboom") + } + + private static func makeUsageStore() -> UsageStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "copilot-snapshot-error-\(UUID().uuidString)"), + 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()) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } +} diff --git a/Tests/CodexBarTests/CopilotUsageFetcherTests.swift b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift new file mode 100644 index 000000000..3ae186a65 --- /dev/null +++ b/Tests/CodexBarTests/CopilotUsageFetcherTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CopilotUsageFetcherTests { + @Test + func `fetchGitHubIdentity uses shared client`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard request.value(forHTTPHeaderField: "Authorization") == "token abc123" else { + throw URLError(.userAuthenticationRequired) + } + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(#"{"login":"testuser","id":123}"#.utf8), response) + } + + let identity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: "abc123", transport: transport) + + #expect(identity.login == "testuser") + #expect(identity.id == 123) + let requests = await transport.requests() + #expect(requests.count == 1) + #expect(requests.first?.url?.host == "api.github.com") + } +} diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 9ad621af0..e59d2ad9e 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct CopilotUsageModelsTests { @Test @@ -273,6 +273,55 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat?.percentRemaining == 25) } + @Test + func `preserves over quota percent remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "paid", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 500, + "remaining": -75, + "percent_remaining": -15, + "quota_id": "premium_interactions" + } + } + } + """) + + let snapshot = try #require(response.quotaSnapshots.premiumInteractions) + #expect(snapshot.percentRemaining == -15) + #expect(snapshot.usedPercent == 115) + #expect(snapshot.overQuotaUsedPercent == 115) + let window = try #require(CopilotUsageFetcher.makeRateWindow(from: snapshot)) + #expect(window.usedPercent == 115) + #expect(window.resetDescription == "115% used") + } + + @Test + func `derives over quota percent from negative remaining`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "paid", + "quota_snapshots": { + "chat": { + "entitlement": 500, + "remaining": -75, + "quota_id": "chat" + } + } + } + """) + + let snapshot = try #require(response.quotaSnapshots.chat) + #expect(snapshot.hasPercentRemaining) + #expect(snapshot.percentRemaining == -15) + #expect(snapshot.usedPercent == 115) + #expect(snapshot.overQuotaUsedPercent == 115) + } + @Test func `marks percent remaining as unavailable when underdetermined`() throws { let response = try Self.decodeFixture( diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index fddb9bcf1..31bcc7eb5 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -10,7 +10,7 @@ struct CostUsageCacheTests { let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v4.json") + #expect(codexURL.lastPathComponent == "codex-v7.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 0cfd90ea3..f73467e71 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -3,6 +3,76 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherTests { + @Test + func `fetcher scopes codex history to selected codex home`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let otherHome = env.root.appendingPathComponent("other-codex-home", isDirectory: true) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "ambient.jsonl", + tokens: 100) + try Self.writeCodexSessionFile(homeRoot: otherHome, env: env, day: day, filename: "managed.jsonl", tokens: 10) + + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let ambient = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + scannerOptions: options) + let managed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: otherHome.path, + scannerOptions: options) + + #expect(ambient.sessionTokens == 100) + #expect(managed.sessionTokens == 10) + } + + @Test + func `fetcher refreshes codex cache when legacy roots metadata is missing`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let managedHome = env.root.appendingPathComponent("managed-codex-home", isDirectory: true) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "ambient.jsonl", + tokens: 100) + try Self.writeCodexSessionFile(homeRoot: managedHome, env: env, day: day, filename: "managed.jsonl", tokens: 10) + + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options(piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot) + let ambient = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + scannerOptions: options, + piScannerOptions: piOptions) + #expect(ambient.sessionTokens == 100) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = nil + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let managed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day.addingTimeInterval(1), + codexHomePath: managedHome.path, + scannerOptions: options, + piScannerOptions: piOptions) + + #expect(managed.sessionTokens == 10) + } + @Test func `fetcher merges native and pi codex history with normalized model names`() async throws { let env = try CostUsageTestEnvironment() @@ -198,4 +268,188 @@ struct CostUsageFetcherTests { totalTokens: 205), ]) } + + @Test + func `fetcher prefers turn context model over token count fallback`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let nativeTurnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": "openai/gpt-5.4", + ], + ] + let nativeTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "model": "gpt-5", + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([nativeTurnContext, nativeTokenCount])) + + let nativeOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) ?? 0 + + #expect(snapshot.daily.first?.modelBreakdowns == [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: cost, + totalTokens: 110), + ]) + } + + @Test + func `force refresh keeps incremental cost cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, 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 model = "openai/gpt-5.4" + + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": ["model": model], + ] + let firstTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([turnContext, firstTokenCount])) + + let nativeOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot) + + let first = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + #expect(first.daily.first?.totalTokens == 110) + + let appendedTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "total_token_usage": [ + "input_tokens": 160, + "cached_input_tokens": 40, + "output_tokens": 16, + ], + ], + ], + ] + try env.jsonl([turnContext, firstTokenCount, appendedTokenCount]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let refreshed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + forceRefresh: true, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + + #expect(refreshed.daily.first?.totalTokens == 176) + } + + 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/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 20af0b54b..4d4ea170b 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodexBarCore @@ -54,39 +55,272 @@ struct CostUsagePricingTests { } @Test - func `codex cost supports gpt55`() { + func `codex cost supports gpt55 bundled fallback`() throws { + let root = try Self.cacheRoot() let cost = CostUsagePricing.codexCostUSD( model: "openai/gpt-5.5-2026-04-23", inputTokens: 100, cachedInputTokens: 10, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5) + #expect(cost == expected) + } + + @Test + func `codex cost applies gpt54 and gpt55 long context rates to full session`() throws { + let root = try Self.cacheRoot() + let gpt54 = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5)) + #expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + + @Test + func `codex cost keeps normal rates at long context input boundary`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 128_000, + modelsDevCacheRoot: root) + + #expect(gpt55 == (272_000.0 * 5e-6) + (128_000.0 * 3e-5)) + } + + @Test + func `codex cost applies long context rates to all cached and non cached input`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 300_000, + cachedInputTokens: 200_000, + outputTokens: 10, + modelsDevCacheRoot: root) + + let cached = 200_000.0 * 1e-6 + let nonCached = 100_000.0 * 1e-5 + let output = 10.0 * 4.5e-5 + + #expect(gpt55 == cached + nonCached + output) + } + + @Test + func `codex priority cost applies model specific fast rates`() { + let gpt54 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + + #expect(gpt54 == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5)) + #expect(gpt55 == (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5)) + #expect(gpt54Mini == (80.0 * 1.5e-6) + (20.0 * 1.5e-7) + (10.0 * 9e-6)) + } + + @Test + func `codex priority cost is unavailable for long context requests`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == nil) + #expect(gpt54Mini == nil) + } - #expect(cost == 90 * 5e-6 + 10 * 5e-7 + 5 * 3e-5) + @Test + func `codex priority cost remains available at priority input boundary`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == (272_000.0 * 1.25e-5) + (10.0 * 7.5e-5)) } @Test - func `codex cost supports gpt55 pro`() { + func `codex models dev pricing uses codex long context threshold`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5, + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } + } + } + } + } + } + """) + + let atBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let aboveBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5)) + #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + + @Test + func `codex cost supports gpt55 pro bundled fallback`() throws { + let root = try Self.cacheRoot() let cost = CostUsagePricing.codexCostUSD( model: "openai/gpt-5.5-pro-2026-04-23", inputTokens: 100, cachedInputTokens: 10, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) - #expect(cost == 100 * 3e-5 + 5 * 1.8e-4) + let expected = (100.0 * 3e-5) + (5.0 * 1.8e-4) + #expect(cost == expected) } @Test - func `codex cost returns zero for research preview model`() { + func `codex cost returns zero for research preview fallback model`() throws { + let root = try Self.cacheRoot() let cost = CostUsagePricing.codexCostUSD( model: "gpt-5.3-codex-spark", inputTokens: 100, cachedInputTokens: 10, - outputTokens: 5) + outputTokens: 5, + modelsDevCacheRoot: root) #expect(cost == 0) #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview") #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.2-codex") == nil) } + @Test + func `codex cost prefers models dev cache over bundled fallback`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { "input": 10, "output": 20, "cache_read": 1 } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (90.0 * 10e-6) + (10.0 * 1e-6) + (5.0 * 20e-6) + #expect(cost == expected) + } + + @Test + func `codex cost lets models dev override research preview fallback`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.3-codex-spark": { + "id": "gpt-5.3-codex-spark", + "cost": { "input": 2, "output": 8, "cache_read": 0.2 } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex-spark", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (90.0 * 2e-6) + (10.0 * 0.2e-6) + (5.0 * 8e-6) + #expect(cost == expected) + #expect(CostUsagePricing.codexDisplayLabel(model: "gpt-5.3-codex-spark") == "Research Preview") + } + + @Test + func `codex cost falls back to bundled pricing when models dev misses provider model`() throws { + let root = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { "input": 10, "output": 20, "cache_read": 1 } + } + } + } + } + """) + + let cost = CostUsagePricing.codexCostUSD( + model: "openai/gpt-5.5", + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (90.0 * 5e-6) + (10.0 * 5e-7) + (5.0 * 3e-5) + #expect(cost == expected) + } + @Test func `normalizes claude opus41 dated variants`() { #expect(CostUsagePricing.normalizeClaudeModel("claude-opus-4-1-20250805") == "claude-opus-4-1") @@ -122,7 +356,8 @@ struct CostUsagePricingTests { cacheReadInputTokens: 0, cacheCreationInputTokens: 0, outputTokens: 5) - #expect(cost == 10 * 5e-6 + 5 * 2.5e-5) + let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5) + #expect(cost == expected) } @Test @@ -135,4 +370,61 @@ struct CostUsagePricingTests { outputTokens: 40) #expect(cost == nil) } + + @Test + func `claude cost prefers models dev cache with threshold pricing`() throws { + let root = try Self.seedModelsDevCache(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + } + } + } + } + } + """) + + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 200_010, + cacheReadInputTokens: 5, + cacheCreationInputTokens: 5, + outputTokens: 5, + modelsDevCacheRoot: root) + + let expected = (200_000.0 * 3e-6) + + (10.0 * 6e-6) + + (5.0 * 0.3e-6) + + (5.0 * 3.75e-6) + + (5.0 * 15e-6) + #expect(cost == expected) + } + + private static func seedModelsDevCache(_ json: String) throws -> URL { + let root = try Self.cacheRoot() + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + ModelsDevCache.save(catalog: catalog, fetchedAt: Date(), cacheRoot: root) + return root + } + + private static func cacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-pricing-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } } diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 197db118c..be44ed389 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -5,6 +5,51 @@ import Testing // swiftlint:disable file_length // swiftlint:disable type_body_length struct CostUsageScannerBreakdownTests { + private typealias Usage = (input: Int, cached: Int, output: Int) + + private func codexTurnContext(timestamp: String, model: String) -> [String: Any] { + [ + "type": "turn_context", + "timestamp": timestamp, + "payload": [ + "model": model, + ], + ] + } + + private func codexTokenCount( + timestamp: String, + model: String, + total: Usage? = nil, + last: Usage? = nil) -> [String: Any] + { + var info: [String: Any] = [ + "model": model, + ] + if let total { + info["total_token_usage"] = [ + "input_tokens": total.input, + "cached_input_tokens": total.cached, + "output_tokens": total.output, + ] + } + if let last { + info["last_token_usage"] = [ + "input_tokens": last.input, + "cached_input_tokens": last.cached, + "output_tokens": last.output, + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info, + ], + ] + } + @Test func `codex daily report parses token counts and caches`() throws { let env = try CostUsageTestEnvironment() @@ -97,6 +142,216 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex daily report prefers last token usage over divergent totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.5" + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + turnContext, + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 20, output: 10), + last: (input: 100, cached: 20, output: 10)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 160, cached: 40, output: 16), + last: (input: 60, cached: 20, output: 6)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 40, cached: 30, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 1050, cached: 930, output: 110), + last: (input: 50, cached: 30, output: 10)), + ])) + + 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) + let expectedCost = CostUsagePricing.codexCostUSD( + model: model, + inputTokens: 250, + cachedInputTokens: 100, + outputTokens: 31) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 250) + #expect(report.data[0].outputTokens == 31) + #expect(report.data[0].totalTokens == 281) + #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `codex total only after divergent totals uses raw delta when it continues`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-raw-continuing.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1050, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 190) + #expect(parsed.lastTotals == nil) + } + + @Test + func `codex total only after divergent totals preserves zero raw dimensions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-stale-dimension.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 900, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1050, cached: 900, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 190) + #expect(packed[safe: 1] == 0) + #expect(parsed.lastTotals == nil) + } + + @Test + func `codex total only after divergent totals can resume from counted baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "mixed-counted-resume.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 40, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 180, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 180) + #expect(parsed.lastTotals?.input == 180) + } + + @Test + func `codex total only after last only counts from last based baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 15) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "last-then-total.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 150, cached: 0, output: 0)), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let packed = parsed.days[dayKey]?["gpt-5.5"] ?? [] + + #expect(packed[safe: 0] == 150) + #expect(parsed.lastTotals?.input == 150) + } + @Test func `codex daily report includes archived sessions and dedupes`() throws { let env = try CostUsageTestEnvironment() @@ -391,6 +646,83 @@ struct CostUsageScannerBreakdownTests { #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } + @Test + func `codex forked child inherits counted parent totals when totals diverge`() 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 parentSessionId = "sess-parent-diverged" + let childSessionId = "sess-child-diverged" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(2.5)) + + _ = try env.writeCodexSessionFile( + day: parentDay, + filename: "rollout-2026-02-27T11-29-28-\(parentSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": parentSessionId, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: parentDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(1)), + model: model, + total: (input: 100, cached: 0, output: 0), + last: (input: 100, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: parentDay.addingTimeInterval(2)), + model: model, + total: (input: 1000, cached: 0, output: 0), + last: (input: 40, cached: 0, output: 0)), + ])) + + _ = 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": parentSessionId, + "timestamp": forkTs, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 140, cached: 0, output: 0)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 170, cached: 0, output: 0)), + ])) + + 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 == 30) + #expect(report.data[0].totalTokens == 30) + } + @Test func `codex forked child subtracts inherited replay from last token usage`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index e7547094a..7600dfc09 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -113,6 +113,51 @@ struct CostUsageScannerClaudeRegressionTests { #expect(parsed.rows.allSatisfy { $0.messageId == nil && $0.requestId == nil }) } + @Test + func `claude opus 4 7 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 23) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/opus-47.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-opus-4-7", + "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-04-23T07: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-7") + #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() @@ -319,6 +364,102 @@ struct CostUsageScannerClaudeRegressionTests { #expect(report.data[0].totalTokens == 255) } + @Test + func `claude forked transcript history dedups globally while new fork rows count`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 22) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "claude-sonnet-4-20250514" + + let copiedHistoryInOriginal: [String: Any] = [ + "type": "assistant", + "timestamp": iso0, + "sessionId": "session-original", + "requestId": "req_copied_history", + "isSidechain": false, + "uuid": "assistant-uuid-copied", + "parentUuid": "parent-uuid-copied", + "message": [ + "id": "msg_copied_history", + "model": model, + "usage": [ + "input_tokens": 100, + "cache_creation_input_tokens": 20, + "cache_read_input_tokens": 10, + "output_tokens": 30, + ], + ], + ] + var copiedHistoryInFork = copiedHistoryInOriginal + copiedHistoryInFork["sessionId"] = "session-fork" + + let originalContinuation: [String: Any] = [ + "type": "assistant", + "timestamp": iso1, + "sessionId": "session-original", + "requestId": "req_original_new", + "isSidechain": false, + "message": [ + "id": "msg_original_new", + "model": model, + "usage": [ + "input_tokens": 40, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 10, + ], + ], + ] + let forkContinuation: [String: Any] = [ + "type": "assistant", + "timestamp": iso2, + "sessionId": "session-fork", + "requestId": "req_fork_new", + "isSidechain": false, + "message": [ + "id": "msg_fork_new", + "model": model, + "usage": [ + "input_tokens": 70, + "cache_creation_input_tokens": 5, + "cache_read_input_tokens": 0, + "output_tokens": 20, + ], + ], + ] + + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/session-original.jsonl", + contents: env.jsonl([copiedHistoryInOriginal, originalContinuation])) + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/session-fork.jsonl", + contents: env.jsonl([copiedHistoryInFork, forkContinuation])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 210) + #expect(report.data[0].cacheCreationTokens == 25) + #expect(report.data[0].cacheReadTokens == 10) + #expect(report.data[0].outputTokens == 60) + #expect(report.data[0].totalTokens == 305) + } + @Test func `claude cross file dedup uses stable path order for same rank sidechains`() throws { let env = try CostUsageTestEnvironment() @@ -393,7 +534,7 @@ struct CostUsageScannerClaudeRegressionTests { } @Test - func `claude cross file dedup does not merge rows without session ids`() throws { + func `claude cross file dedup uses provider ids when session id is missing`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -458,9 +599,9 @@ struct CostUsageScannerClaudeRegressionTests { options: options) #expect(report.data.count == 1) - #expect(report.data[0].inputTokens == 30) - #expect(report.data[0].outputTokens == 3) - #expect(report.data[0].totalTokens == 33) + #expect(report.data[0].inputTokens == 10) + #expect(report.data[0].outputTokens == 1) + #expect(report.data[0].totalTokens == 11) } @Test diff --git a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift new file mode 100644 index 000000000..ba4fd6a33 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift @@ -0,0 +1,179 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +import Testing +@testable import CodexBarCore + +struct CostUsageScannerCodexPriorityTests { + @Test + func `parses priority turn metadata without exposing request body`() { + let body = "INFO thread_id=11111111-1111-1111-1111-111111111111 " + + "turn.id=22222222-2222-2222-2222-222222222222 websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","instructions":"secret prompt"}"# + + let parsed = CostUsageScanner.parseCodexPriorityTraceRow(timestamp: "2026-05-10T12:00:00Z", body: body) + + #expect(parsed?.threadID == "11111111-1111-1111-1111-111111111111") + #expect(parsed?.turnID == "22222222-2222-2222-2222-222222222222") + #expect(parsed?.model == "gpt-5.5") + #expect(parsed?.timestamp == "2026-05-10T12:00:00Z") + } + + @Test + func `ignores non priority malformed and non response request rows`() { + let prefix = "thread_id=thread turn.id=turn websocket request: " + + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"session.update","service_tier":"priority"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create","service_tier":"default"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"#) == nil) + } + + @Test + func `reads priority turns from sqlite logs table`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":"private"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: """ + thread_id=thread-b turn.id=turn-b websocket request: {"type":"response.create","model":"gpt-5.5"} + """) + + let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + #expect(turns.keys.sorted() == ["turn-a"]) + #expect(turns["turn-a"]?.threadID == "thread-a") + #expect(turns["turn-a"]?.model == "gpt-5.5") + } + + @Test + func `sqlite scan only returns priority turns in requested day range`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let previousDay = try #require(Calendar.current.date(byAdding: .day, value: -1, to: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: env.isoString(for: previousDay), + body: "thread_id=thread-old turn.id=turn-old websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: env.isoString(for: day), + body: "thread_id=thread-new turn.id=turn-new websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: dayKey, + untilDayKey: dayKey) + + #expect(turns.keys.sorted() == ["turn-new"]) + } + + @Test + func `sqlite scan uses local day boundaries for integer timestamps`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + + var components = DateComponents() + components.calendar = Calendar.current + components.year = 2026 + components.month = 5 + components.day = 10 + let dayStart = try #require(components.date) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: dayStart) + let previousSecond = try #require(Calendar.current.date(byAdding: .second, value: -1, to: dayStart)) + let nextSecond = try #require(Calendar.current.date(byAdding: .second, value: 1, to: dayStart)) + + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(previousSecond.timeIntervalSince1970), + body: "thread_id=thread-before turn.id=turn-before websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(nextSecond.timeIntervalSince1970), + body: "thread_id=thread-after turn.id=turn-after websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: dayKey, + untilDayKey: dayKey) + + #expect(turns.keys.sorted() == ["turn-after"]) + } + + static func createTestLogsDatabase(at dbURL: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db, "create table logs (ts integer not null, feedback_log_body text)") + } + + static func insertTestLog(dbURL: URL, timestamp: String, body: String) throws { + try self.insertTestLog(dbURL: dbURL, epochSeconds: self.epochSeconds(timestamp), body: body) + } + + static func insertTestLog(dbURL: URL, epochSeconds: Int64, body: String) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "insert into logs (ts, feedback_log_body) 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_int64(stmt, 1, epochSeconds) + sqlite3_bind_text(stmt, 2, body, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func epochSeconds(_ timestamp: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: timestamp) else { return 0 } + return Int64(date.timeIntervalSince1970) + } + + 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 enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} +#endif diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift new file mode 100644 index 000000000..e52a3dd47 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -0,0 +1,252 @@ +import Foundation +#if canImport(SQLite3) +import Testing +@testable import CodexBarCore + +struct CostUsageScannerPriorityTests { + @Test + func `codex daily report applies gpt55 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + 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 entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report applies gpt54 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + 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 entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.4"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3, model: "gpt-5.4") + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 2.5e-6) + (20.0 * 2.5e-7) + (10.0 * 1.5e-5) + let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report keeps base cost when sqlite metadata is missing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso1, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expected = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == expected) + } + + @Test + func `codex pricing skips priority surcharge for long context rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + 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 entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso1, input: 272_001, cached: 0, output: 10), + ["type": "event_msg", "timestamp": iso2, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso2, input: 300_000, cached: 0, output: 5), + self.tokenCount(timestamp: iso3, input: 100_001, cached: 0, output: 5), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso2) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardTurnBase = (272_001.0 * 1e-5) + (10.0 * 4.5e-5) + let standardFirstRow = (300_000.0 * 1e-5) + (5.0 * 4.5e-5) + let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) + + let expected = standardTurnBase + standardFirstRow + prioritySecondRow + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + + @Test + func `codex cumulative totals do not trigger long context pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + 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 entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.totalTokenCount(timestamp: iso1, input: 120_000, cached: 60000, output: 100), + self.totalTokenCount(timestamp: iso2, input: 240_000, cached: 120_000, output: 200), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.totalTokenCount(timestamp: iso3, input: 360_000, cached: 180_000, output: 300), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardRow = (Double(60000) * 5e-6) + (Double(60000) * 5e-7) + (Double(100) * 3e-5) + let priorityRow = (Double(60000) * 1.25e-5) + (Double(60000) * 1.25e-6) + (Double(100) * 7.5e-5) + let expected = standardRow + standardRow + priorityRow + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + + private func tokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + + private func totalTokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + + private func insertPriorityTrace(dbURL: URL, timestamp: String, model: String = "gpt-5.5") throws { + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: timestamp, + body: "thread_id=thread turn.id=priority-turn websocket request: " + + #"{"type":"response.create","model":""# + model + #"","service_tier":"priority"}"#) + } +} +#endif diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index c4daa0075..2b9dd7abe 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -150,6 +150,81 @@ struct CostUsageScannerTests { #expect(claudeReport.data[0].totalTokens == 300) } + @Test + func `claude report preserves per-request threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) + let first = env.isoString(for: day) + let second = env.isoString(for: day.addingTimeInterval(1)) + let model = "claude-sonnet-4-6" + let firstEntry: [String: Any] = [ + "type": "assistant", + "timestamp": first, + "requestId": "req_one", + "message": [ + "id": "msg_one", + "model": model, + "usage": [ + "input_tokens": 150_000, + "output_tokens": 0, + ], + ], + ] + let secondEntry: [String: Any] = [ + "type": "assistant", + "timestamp": second, + "requestId": "req_two", + "message": [ + "id": "msg_two", + "model": model, + "usage": [ + "input_tokens": 150_000, + "output_tokens": 0, + ], + ], + ] + + _ = try env.writeClaudeProjectFile( + relativePath: "project-a/threshold.jsonl", + contents: env.jsonl([firstEntry, secondEntry])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: options) + let expectedRequestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let expectedCost = expectedRequestCost * 2 + + #expect(report.data.count == 1) + #expect(report.data.first?.inputTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) + } + @Test func `claude parses large lines with usage at tail`() throws { let env = try CostUsageTestEnvironment() @@ -340,6 +415,91 @@ struct CostUsageScannerTests { #expect(packed[2] == 6) } + @Test + func `codex incremental parsing keeps current turn id`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + 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 = "openai/gpt-5.5" + let turnID = "22222222-2222-2222-2222-222222222222" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + ], + ] + let taskStarted: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "task_started", + "id": turnID, + ], + ] + let firstTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "priority-session.jsonl", + contents: env.jsonl([turnContext, taskStarted, firstTokenCount])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + #expect(first.lastCodexTurnID == turnID) + #expect(first.rows.map(\.turnID) == [turnID]) + + let secondTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso3, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 160, + "cached_input_tokens": 40, + "output_tokens": 16, + ], + ], + ], + ] + try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let delta = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + startOffset: first.parsedBytes, + initialModel: first.lastModel, + initialTotals: first.lastTotals, + initialCodexTurnID: first.lastCodexTurnID) + + #expect(delta.lastCodexTurnID == turnID) + #expect(delta.rows.map(\.turnID) == [turnID]) + #expect(delta.rows.first?.input == 60) + #expect(delta.rows.first?.cached == 20) + #expect(delta.rows.first?.output == 6) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CrofMenuCardTests.swift b/Tests/CodexBarTests/CrofMenuCardTests.swift new file mode 100644 index 000000000..ade932a3a --- /dev/null +++ b/Tests/CodexBarTests/CrofMenuCardTests.swift @@ -0,0 +1,44 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CrofMenuCardTests { + @Test + func `model shows request count and avoids duplicate credits section`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.crof]) + let snapshot = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .crof, + 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.creditsText == nil) + #expect(model.metrics.map(\.title) == ["Requests", "Credits"]) + #expect(model.metrics.first?.percent == 99) + #expect(model.metrics.first?.resetText?.hasPrefix("Resets") == true) + #expect(model.metrics.first?.detailRightText == "998 requests left") + #expect(model.metrics.last?.resetText == "$10.00") + } +} diff --git a/Tests/CodexBarTests/CrofProviderImplementationTests.swift b/Tests/CodexBarTests/CrofProviderImplementationTests.swift new file mode 100644 index 000000000..ed30b5200 --- /dev/null +++ b/Tests/CodexBarTests/CrofProviderImplementationTests.swift @@ -0,0 +1,52 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CrofProviderImplementationTests { + @Test + func `availability uses crof environment token`() throws { + let settings = try Self.makeSettings(suite: "CrofProviderImplementationTests-env") + let implementation = CrofProviderImplementation() + + let context = ProviderAvailabilityContext( + provider: .crof, + settings: settings, + environment: [CrofSettingsReader.apiKeyEnvironmentKeys[0]: "env-token"]) + + #expect(implementation.isAvailable(context: context)) + } + + @Test + func `availability uses stored crof API token`() throws { + let settings = try Self.makeSettings(suite: "CrofProviderImplementationTests-settings") + settings.crofAPIToken = "stored-token" + let implementation = CrofProviderImplementation() + + let context = ProviderAvailabilityContext(provider: .crof, settings: settings, environment: [:]) + + #expect(implementation.isAvailable(context: context)) + } + + @Test + func `availability rejects missing crof API token`() throws { + let settings = try Self.makeSettings(suite: "CrofProviderImplementationTests-missing") + settings.crofAPIToken = " " + let implementation = CrofProviderImplementation() + + let context = ProviderAvailabilityContext(provider: .crof, settings: settings, environment: [:]) + + #expect(!implementation.isAvailable(context: context)) + } + + private static func makeSettings(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/CrofUsageFetcherTests.swift b/Tests/CodexBarTests/CrofUsageFetcherTests.swift new file mode 100644 index 000000000..43ae3d99d --- /dev/null +++ b/Tests/CodexBarTests/CrofUsageFetcherTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CrofUsageFetcherTests { + @Test + func `usage URL points at public usage API`() { + #expect(CrofUsageFetcher.usageURL.absoluteString == "https://crof.ai/usage_api/") + } + + @Test + func `usage response parses credits and request quota`() throws { + let json = """ + {"credits":10.0,"requests_plan":1000,"usable_requests":998} + """ + + let snapshot = try CrofUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + + #expect(snapshot.credits == 10) + #expect(snapshot.requestsPlan == 1000) + #expect(snapshot.usableRequests == 998) + } + + @Test + func `usage snapshot maps usable requests to remaining quota`() { + let snapshot = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: Date(timeIntervalSince1970: 1_777_800_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 1) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.primary?.resetDescription == "998 requests left") + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.secondary?.resetDescription == "$10.00") + #expect(usage.identity?.providerID == .crof) + #expect(usage.identity?.loginMethod == "API key") + } + + @Test + func `usage snapshot floors credit balance to cents`() { + let snapshot = CrofUsageSnapshot( + credits: 9.9999, + requestsPlan: 1000, + usableRequests: 998) + + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "$9.99") + } + + @Test + func `usage snapshot resets requests at next America Chicago midnight`() throws { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let updatedAt = try #require(utc.date(from: DateComponents( + year: 2026, + month: 5, + day: 8, + hour: 18, + minute: 30))) + let expectedReset = try #require(utc.date(from: DateComponents( + year: 2026, + month: 5, + day: 9, + hour: 5))) + let snapshot = CrofUsageSnapshot( + credits: 10, + requestsPlan: 1000, + usableRequests: 998, + updatedAt: updatedAt) + + #expect(snapshot.toUsageSnapshot().primary?.resetsAt == expectedReset) + } + + @Test + func `usage snapshot clamps overreported usable requests`() { + let snapshot = CrofUsageSnapshot( + credits: 0, + requestsPlan: 1000, + usableRequests: 1200) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + } + + @Test + func `usage snapshot treats zero plan as exhausted`() { + let snapshot = CrofUsageSnapshot( + credits: 0, + requestsPlan: 0, + usableRequests: 0) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 100) + } + + @Test + func `fetch sends bearer token`() async throws { + defer { + CrofStubURLProtocol.handler = nil + CrofStubURLProtocol.requests = [] + } + CrofStubURLProtocol.requests = [] + CrofStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer crof-test") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + return try Self.makeResponse( + url: url, + body: #"{"credits":10.0,"requests_plan":1000,"usable_requests":998}"#) + } + + let snapshot = try await CrofUsageFetcher.fetchUsage(apiKey: "crof-test", session: Self.makeSession()) + + #expect(snapshot.usableRequests == 998) + #expect(CrofStubURLProtocol.requests.map(\.url?.absoluteString) == ["https://crof.ai/usage_api/"]) + } + + @Test + func `descriptor supports auto and API source modes`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .crof) + #expect(descriptor.metadata.displayName == "Crof") + #expect(descriptor.metadata.dashboardURL == "https://crof.ai/dashboard") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-crof") + } + + @Test + func `settings reader uses CROF_API_KEY`() { + let token = CrofSettingsReader.apiKey(environment: [ + CrofSettingsReader.apiKeyEnvironmentKeys[0]: " crof-token ", + ]) + + #expect(token == "crof-token") + } + + @Test + func `token resolver uses crof environment token`() { + let env = [CrofSettingsReader.apiKeyEnvironmentKeys[0]: "crof-token"] + let resolution = ProviderTokenResolver.crofResolution(environment: env) + + #expect(resolution?.token == "crof-token") + #expect(resolution?.source == .environment) + } + + @Test + func `config API key override feeds crof environment`() { + let config = ProviderConfig(id: .crof, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .crof, + config: config) + + #expect(env[CrofSettingsReader.apiKeyEnvironmentKeys[0]] == "config-token") + #expect(ProviderTokenResolver.crofToken(environment: env) == "config-token") + } + + @Test + func `config API key leaves existing crof environment token alone`() { + let key = CrofSettingsReader.apiKeyEnvironmentKeys[0] + let config = ProviderConfig(id: .crof, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [key: "env-token"], + provider: .crof, + config: config) + + #expect(env[key] == "env-token") + #expect(ProviderTokenResolver.crofToken(environment: env) == "env-token") + } + + @Test + func `missing credentials fetch call throws missing credentials`() async { + do { + _ = try await CrofUsageFetcher.fetchUsage(apiKey: " ") + Issue.record("Expected missingCredentials error") + } catch let error as CrofUsageError { + #expect(error == .missingCredentials) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CrofStubURLProtocol.self] + return URLSession(configuration: config) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) throws -> (HTTPURLResponse, Data) + { + guard let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"]) + else { + throw URLError(.badServerResponse) + } + return (response, Data(body.utf8)) + } +} + +final class CrofStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "crof.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + 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/CursorEnterpriseUsageTests.swift b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift new file mode 100644 index 000000000..c6b5dd19b --- /dev/null +++ b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CursorEnterpriseUsageTests { + @Test + func `parses enterprise overall and pooled usage summary`() throws { + // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; + // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` + // (shared team pool). Both blocks use cents like the existing `plan` block. + let json = """ + { + "billingCycleStart": "2026-04-01T00:00:00.000Z", + "billingCycleEnd": "2026-05-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "isUnlimited": false, + "individualUsage": { + "overall": { + "enabled": true, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 0, + "limit": null, + "remaining": null + }, + "pooled": { + "enabled": true, + "used": 12725135, + "limit": 28122000, + "remaining": 15396865 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.limitType == "team") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 7384) + #expect(summary.individualUsage?.overall?.limit == 10000) + #expect(summary.individualUsage?.overall?.remaining == 2616) + #expect(summary.teamUsage?.pooled?.used == 12_725_135) + #expect(summary.teamUsage?.pooled?.limit == 28_122_000) + } + + @Test + func `enterprise overall drives headline percent and dollars`() throws { + // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of + // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% + // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over + // any team pool, and USD figures must reflect the same source. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-04-01T00:00:00.000Z", + billingCycleEnd: "2026-05-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // Headline: $73.84 / $100 -> 73.84% (matches Cursor's own dashboard). + // Allow a tiny tolerance for floating-point division (7384/10000 * 100). + #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) + #expect(snapshot.planUsedUSD == 73.84) + #expect(snapshot.planLimitUSD == 100.0) + #expect(snapshot.autoPercentUsed == nil) + #expect(snapshot.apiPercentUsed == nil) + + let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) + #expect(abs(primaryPercent - 73.84) < 0.0001) + } + + @Test + func `enterprise pooled fallback used when no individual data`() { + // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface + // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: nil, + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed > 45.0) + #expect(snapshot.planPercentUsed < 45.5) + #expect(snapshot.planUsedUSD == 127_251.35) + #expect(snapshot.planLimitUSD == 281_220.0) + } + + @Test + func `existing plan block still wins over overall and pooled`() { + // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` + // blocks, the existing percent precedence must remain intact. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 1500, + limit: 5000, + remaining: 3500, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: 30.0), + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.planUsedUSD == 15.0) + #expect(snapshot.planLimitUSD == 50.0) + } +} diff --git a/Tests/CodexBarTests/CursorLoginRunnerTests.swift b/Tests/CodexBarTests/CursorLoginRunnerTests.swift new file mode 100644 index 000000000..16632ebe0 --- /dev/null +++ b/Tests/CodexBarTests/CursorLoginRunnerTests.swift @@ -0,0 +1,115 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CursorLoginRunnerTests { + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + @Test + func `login opens Cursor auth URL in browser before polling cookies`() async { + var openedURLs: [URL] = [] + var phases: [String] = [] + + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + openURL: { url in + openedURLs.append(url) + return true + }, + loadSnapshot: { + CursorStatusSnapshot( + planPercentUsed: 12, + planUsedUSD: 1, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: "cursor@example.com", + accountName: nil, + rawJSON: nil) + }, + sleeper: { _ in }, + resetSessionCache: {}) + + let result = await runner.run { phase in + switch phase { + case .loading: phases.append("loading") + case .waitingLogin: phases.append("waitingLogin") + case .success: phases.append("success") + case let .failed(message): phases.append("failed:\(message)") + } + } + + #expect(openedURLs == [CursorLoginRunner.authURL]) + #expect(phases == ["loading", "waitingLogin", "success"]) + #expect(result.email == "cursor@example.com") + } + + @Test + func `login clears stale session state before opening auth URL`() async { + let events = LockedArray() + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + openURL: { _ in + events.append("open") + return true + }, + loadSnapshot: { + events.append("poll") + throw CursorStatusProbeError.noSessionCookie + }, + sleeper: { _ in }, + resetSessionCache: { + events.append("reset") + }) + + _ = await runner.run { _ in } + + #expect(Array(events.snapshot().prefix(2)) == ["reset", "open"]) + } + + @Test + func `login reports launch failure when browser cannot open`() async { + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + openURL: { _ in false }, + loadSnapshot: { + Issue.record("Should not poll cookies when browser launch fails") + throw CursorStatusProbeError.noSessionCookie + }, + sleeper: { _ in }, + resetSessionCache: {}) + + let result = await runner.run { _ in } + + guard case let .failed(message) = result.outcome else { + Issue.record("Expected failed outcome") + return + } + #expect(message.contains("Could not open Cursor login")) + } +} diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift new file mode 100644 index 000000000..0aba8b4a8 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct DeepSeekSettingsReaderTests { + @Test + func `reads DEEPSEEK_API_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-abc123"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-abc123") + } + + @Test + func `falls back to DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_KEY": "sk-fallback"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-fallback") + } + + @Test + func `DEEPSEEK_API_KEY takes priority over DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-primary", "DEEPSEEK_KEY": "sk-secondary"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-primary") + } + + @Test + func `trims whitespace`() { + let env = ["DEEPSEEK_API_KEY": " sk-trimmed "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["DEEPSEEK_API_KEY": "\"sk-quoted\""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["DEEPSEEK_KEY": "'sk-single'"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-single") + } + + @Test + func `returns nil when no key present`() { + #expect(DeepSeekSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["DEEPSEEK_API_KEY": ""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["DEEPSEEK_API_KEY": " "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } +} + +struct DeepSeekProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["DEEPSEEK_API_KEY": "sk-resolve-test"] + let resolution = ProviderTokenResolver.deepseekResolution(environment: env) + #expect(resolution?.token == "sk-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.deepseekResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift new file mode 100644 index 000000000..8bac9bd25 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -0,0 +1,240 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekUsageFetcherTests { + @Test + func `parses USD balance response`() 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)) + #expect(snapshot.isAvailable == true) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.grantedBalance == 10.0) + #expect(snapshot.toppedUpBalance == 40.0) + } + + @Test + func `parses CNY balance response`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "110.00", + "granted_balance": "10.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "CNY") + #expect(snapshot.totalBalance == 110.0) + #expect(snapshot.toppedUpBalance == 100.0) + } + + @Test + func `prefers USD when both currencies present`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + }, + { + "currency": "USD", + "total_balance": "20.00", + "granted_balance": "5.00", + "topped_up_balance": "15.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 20.0) + } + + @Test + func `prefers positive CNY balance over empty USD balance`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "0.00", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + }, + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.currency == "CNY") + #expect(snapshot.totalBalance == 100.0) + #expect(usage.primary?.resetDescription?.contains("¥100.00") == true) + } + + @Test + func `zero balance prompts top up even when unavailable`() throws { + let json = """ + { + "is_available": false, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "0.00", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "$0.00 — add credits at platform.deepseek.com") + #expect(usage.identity?.loginMethod == nil) + } + + @Test + func `full bar when balance available`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "5.00", + "granted_balance": "0.00", + "topped_up_balance": "5.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription?.contains("$5.00") == true) + #expect(usage.identity?.loginMethod == nil) + } + + @Test + func `throws on malformed balance string`() { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "not-a-number", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `empty balance_infos returns unavailable snapshot`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + #expect(snapshot.totalBalance == 0.0) + } + + @Test + func `throws on invalid JSON root`() { + let json = "[{ \"is_available\": true }]" + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `balance description includes paid and granted breakdown`() 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() + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("$50.00")) + #expect(detail.contains("$40.00")) + #expect(detail.contains("$10.00")) + } + + @Test + func `CNY balance uses yen symbol`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("¥")) + } +} diff --git a/Tests/CodexBarTests/DeepgramProviderTests.swift b/Tests/CodexBarTests/DeepgramProviderTests.swift new file mode 100644 index 000000000..0d818695d --- /dev/null +++ b/Tests/CodexBarTests/DeepgramProviderTests.swift @@ -0,0 +1,252 @@ +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct DeepgramProviderTests { + @Test + func `deepgram field kinds and bindings`() throws { + let suite = "DeepgramProviderTests-field-kinds" + 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 implementation = DeepgramProviderImplementation() + let fields = implementation.settingsFields(context: context) + + let apiField = try #require(fields.first(where: { $0.id == "deepgram-api-key" })) + let projectField = try #require(fields.first(where: { $0.id == "deepgram-project-id" })) + + #expect(apiField.kind == .secure) + #expect(projectField.kind == .plain) + + // Verify bindings update the SettingsStore + apiField.binding.wrappedValue = "dg_test_token" + #expect(settings.deepgramAPIKey == "dg_test_token") + + projectField.binding.wrappedValue = "proj-1234" + #expect(settings.deepgramProjectID == "proj-1234") + } + + @Test + nonisolated func `parses usage breakdown response into visible usage notes`() throws { + let body = #""" + { + "start": "2025-01-16", + "end": "2025-01-23", + "resolution": { + "units": "day", + "amount": 1 + }, + "results": [ + { + "hours": 1619.7242069444444, + "total_hours": 1621.7395791666668, + "agent_hours": 41.33564388888889, + "tokens_in": 1200, + "tokens_out": 340, + "tts_characters": 9158866, + "requests": 373381, + "grouping": { + "start": "2025-01-16", + "end": "2025-01-16", + "endpoint": "listen" + } + }, + { + "hours": 2.25, + "total_hours": 3.5, + "requests": 19, + "grouping": { + "start": "2025-01-17", + "end": "2025-01-17", + "endpoint": "speak" + } + } + ] + } + """# + + let updatedAt = Date(timeIntervalSince1970: 123) + let snapshot = try DeepgramUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + projectID: "project-123", + updatedAt: updatedAt) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.requests == 373_400) + #expect(snapshot.hours == 1621.9742069444444) + #expect(snapshot.totalHours == 1625.2395791666668) + #expect(snapshot.agentHours == 41.33564388888889) + #expect(snapshot.tokensIn == 1200) + #expect(snapshot.tokensOut == 340) + #expect(snapshot.ttsCharacters == 9_158_866) + #expect(usage.deepgramUsage?.requests == 373_400) + #expect(usage.loginMethod(for: .deepgram) == "Project: project-123") + #expect(usage.deepgramUsage?.displayLines == [ + "Requests: 373,400", + "1,622.0 audio hours · 1,625.2 billable hours", + "41.3 agent hours · 1,540 tokens · 9,158,866 TTS chars", + "Period: 2025-01-16 to 2025-01-23", + ]) + } + + @Test + nonisolated func `fetch usage calls breakdown endpoint with token auth`() async throws { + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/projects/project-123/usage/breakdown") + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + #expect(components?.queryItems?.contains(URLQueryItem(name: "start", value: "2025-01-16")) == true) + #expect(components?.queryItems?.contains(URLQueryItem(name: "end", value: "2025-01-23")) == true) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Token dg-test") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "start": "2025-01-16", + "end": "2025-01-23", + "resolution": { + "units": "day", + "amount": 1 + }, + "results": [ + { + "hours": 1.5, + "total_hours": 2, + "requests": 7 + } + ] + } + """# + return Self.makeResponse(url: url, body: body) + } + + let usage = try await DeepgramUsageFetcher.fetchUsage( + apiKey: " dg-test ", + projectID: " project-123 ", + query: DeepgramUsageQuery(start: "2025-01-16", end: "2025-01-23"), + environment: ["DEEPGRAM_API_URL": "https://deepgram.test/v1"], + transport: transport) + + #expect(usage.projectID == "project-123") + #expect(usage.requests == 7) + #expect(usage.hours == 1.5) + #expect(usage.totalHours == 2) + + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + nonisolated func `fetch usage discovers projects when project id is omitted`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Token dg-test") + + switch url.path { + case "/v1/projects": + return Self.makeResponse(url: url, body: #""" + { + "projects": [ + { "project_id": "project-a", "name": "Alpha" }, + { "project_id": "project-b", "name": "Beta" } + ] + } + """#) + + case "/v1/projects/project-a/usage/breakdown": + return Self.makeResponse(url: url, body: #""" + { + "start": "2025-01-16", + "end": "2025-01-23", + "results": [ + { "hours": 1, "total_hours": 2, "requests": 3 } + ] + } + """#) + + case "/v1/projects/project-b/usage/breakdown": + return Self.makeResponse(url: url, body: #""" + { + "start": "2025-01-17", + "end": "2025-01-24", + "results": [ + { "hours": 4, "total_hours": 5, "requests": 6 } + ] + } + """#) + + default: + throw URLError(.badURL) + } + } + + let usage = try await DeepgramUsageFetcher.fetchUsage( + apiKey: "dg-test", + environment: ["DEEPGRAM_API_URL": "https://deepgram.test/v1"], + transport: transport) + + #expect(usage.projectID == "all") + #expect(usage.projectCount == 2) + #expect(usage.requests == 9) + #expect(usage.hours == 5) + #expect(usage.totalHours == 7) + #expect(usage.start == "2025-01-16") + #expect(usage.end == "2025-01-24") + #expect(usage.toUsageSnapshot().loginMethod(for: .deepgram) == "2 projects") + + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/v1/projects", + "/v1/projects/project-a/usage/breakdown", + "/v1/projects/project-b/usage/breakdown", + ]) + } + + private nonisolated static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift new file mode 100644 index 000000000..9ce12c25e --- /dev/null +++ b/Tests/CodexBarTests/DoubaoProviderTests.swift @@ -0,0 +1,39 @@ +import CodexBarCore +import Foundation +import Testing + +struct DoubaoProviderTests { + @Test + func `usage snapshot exposes request usage window`() { + let resetDate = Date(timeIntervalSince1970: 1_742_771_200) + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 80, + limitRequests: 100, + resetTime: resetDate, + updatedAt: resetDate, + apiKeyValid: true) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + #expect(usage.primary?.resetDescription == "20/100 requests") + #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.identity?.providerID == .doubao) + } + + @Test + func `usage snapshot shows active key when headers are absent`() { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let snapshot = DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: now, + apiKeyValid: true) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription == "Active - check dashboard for details") + } +} diff --git a/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift b/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift new file mode 100644 index 000000000..26ce7014a --- /dev/null +++ b/Tests/CodexBarTests/ElevenLabsUsageFetcherTests.swift @@ -0,0 +1,178 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ElevenLabsUsageFetcherTests { + @Test + func `parses subscription response into usage snapshot`() throws { + let body = #""" + { + "tier": "creator", + "character_count": 25000, + "character_limit": 100000, + "voice_slots_used": 2, + "voice_limit": 10, + "professional_voice_slots_used": 1, + "professional_voice_limit": 2, + "current_overage": {"amount": "0", "currency": "usd"}, + "status": "active", + "next_character_count_reset_unix": 1738356858 + } + """# + + let snapshot = try ElevenLabsUsageFetcher._parseSnapshotForTesting( + Data(body.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.characterCount == 25000) + #expect(snapshot.characterLimit == 100_000) + #expect(snapshot.usedPercent == 25) + #expect(snapshot.remainingCharacters == 75000) + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "25,000 / 100,000 credits") + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_738_356_858)) + #expect(usage.loginMethod(for: .elevenlabs) == "Creator") + #expect(usage.extraRateWindows?.count == 2) + } + + @Test + func `fetch usage sends xi api key header`() async throws { + let registered = URLProtocol.registerClass(ElevenLabsStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(ElevenLabsStubURLProtocol.self) + } + ElevenLabsStubURLProtocol.handler = nil + } + + ElevenLabsStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/user/subscription") + #expect(request.value(forHTTPHeaderField: "xi-api-key") == "xi-test") + #expect(request.timeoutInterval == 15) + + let body = #""" + { + "tier": "starter", + "character_count": 1000, + "character_limit": 10000, + "status": "active" + } + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let usage = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: " xi-test ", + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "https://elevenlabs.test"]) + + #expect(usage.characterCount == 1000) + #expect(usage.characterLimit == 10000) + #expect(usage.usedPercent == 10) + } + + @Test + func `fetch usage accepts versioned API base with trailing slash`() async throws { + let registered = URLProtocol.registerClass(ElevenLabsStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(ElevenLabsStubURLProtocol.self) + } + ElevenLabsStubURLProtocol.handler = nil + } + + ElevenLabsStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.path == "/v1/user/subscription") + + let body = #""" + { + "tier": "starter", + "character_count": 1000, + "character_limit": 10000, + "status": "active" + } + """# + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let usage = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: "xi-test", + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "https://elevenlabs.test/v1/"]) + + #expect(usage.characterCount == 1000) + } + + @Test + func `non success fetch throws generic HTTP error`() async throws { + let registered = URLProtocol.registerClass(ElevenLabsStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(ElevenLabsStubURLProtocol.self) + } + ElevenLabsStubURLProtocol.handler = nil + } + + ElevenLabsStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"detail":"bad xi-test"}"#, statusCode: 500) + } + + do { + _ = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: "xi-test", + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "https://elevenlabs.test"]) + Issue.record("Expected ElevenLabsUsageError.apiError") + } catch let error as ElevenLabsUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got \(error)") + return + } + #expect(message == "HTTP 500") + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class ElevenLabsStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "elevenlabs.test" + } + + 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/FactoryManualCredentialTests.swift b/Tests/CodexBarTests/FactoryManualCredentialTests.swift new file mode 100644 index 000000000..33edb0f9b --- /dev/null +++ b/Tests/CodexBarTests/FactoryManualCredentialTests.swift @@ -0,0 +1,135 @@ +import CodexBarCore +import Foundation +import Testing + +extension FactoryStatusProbeFetchTests { + @Test + func `rejects malformed manual override before cached cookies`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + CookieHeaderCache.clear(provider: .factory) + } + FactoryStubURLProtocol.requests = [] + CookieHeaderCache.store(provider: .factory, cookieHeader: "session=cached", sourceLabel: "Chrome") + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: "{}", statusCode: 200) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + await #expect { + _ = try await probe.fetch(cookieHeaderOverride: "definitely not a cookie or bearer") + } throws: { error in + guard case FactoryStatusProbeError.noSessionCookie = error else { return false } + return true + } + #expect(FactoryStubURLProtocol.requests.isEmpty) + } + + @Test + func `falls back to bearer authorization when pasted cookie is stale`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.value(forHTTPHeaderField: "Cookie")?.contains("stale-session") == true { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer factory-access-token" else { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + }, + "userProfile": { + "id": "user-1", + "email": "user@example.com" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + if url.host == "api.factory.ai", url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000, + "usedRatio": 0.10 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch( + cookieHeaderOverride: "Cookie: session=stale-session\nAuthorization: Bearer factory-access-token") + + #expect(snapshot.userId == "user-1") + #expect(snapshot.standardUserTokens == 100) + #expect(snapshot.standardAllowance == 1000) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET auth.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + "GET api.factory.ai/api/organization/subscription/usage?useCache=true&userId=user-1", + ]) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestTrace() -> [String] { + FactoryStubURLProtocol.requests.compactMap { request in + guard let url = request.url else { return nil } + let query = url.query.map { "?\($0)" } ?? "" + return "\(request.httpMethod ?? "?") \(url.host ?? "unknown")\(url.path)\(query)" + } + } +} diff --git a/Tests/CodexBarTests/FactoryProviderImplementationTests.swift b/Tests/CodexBarTests/FactoryProviderImplementationTests.swift new file mode 100644 index 000000000..f37f19446 --- /dev/null +++ b/Tests/CodexBarTests/FactoryProviderImplementationTests.swift @@ -0,0 +1,82 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct FactoryProviderImplementationTests { + @Test + func `extra usage balance respects optional usage setting`() throws { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 25, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: Date(timeIntervalSince1970: 0)), + updatedAt: Date(timeIntervalSince1970: 0)) + + var hiddenEntries: [ProviderMenuEntry] = [] + let hiddenContext = try Self.context(snapshot: snapshot, showOptionalUsage: false) + FactoryProviderImplementation().appendUsageMenuEntries( + context: hiddenContext, + entries: &hiddenEntries) + #expect(hiddenEntries.isEmpty) + + var visibleEntries: [ProviderMenuEntry] = [] + let visibleContext = try Self.context(snapshot: snapshot, showOptionalUsage: true) + FactoryProviderImplementation().appendUsageMenuEntries( + context: visibleContext, + entries: &visibleEntries) + + guard case let .text(title, style) = try #require(visibleEntries.first) else { + Issue.record("Expected Factory extra usage balance menu text") + return + } + #expect(title == "Extra usage balance: $25.00") + #expect(style == .primary) + } + + private static func context( + snapshot: UsageSnapshot, + showOptionalUsage: Bool) throws -> ProviderMenuUsageContext + { + let suite = "FactoryProviderImplementationTests-\(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(), + 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.showOptionalCreditsAndExtraUsage = showOptionalUsage + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + return ProviderMenuUsageContext( + provider: .factory, + store: store, + settings: settings, + metadata: FactoryProviderDescriptor.descriptor.metadata, + snapshot: snapshot) + } +} diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index 660091a5f..f25c09304 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -98,6 +98,7 @@ struct FactoryStatusProbeFetchTests { #expect(Self.requestTrace() == [ "GET app.factory.ai/api/app/auth/me", "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", "GET api.factory.ai/api/organization/subscription/usage?useCache=true", ]) await FactorySessionStore.shared.clearSession() @@ -219,6 +220,7 @@ struct FactoryStatusProbeFetchTests { "GET app.factory.ai/api/app/auth/me", "POST api.workos.com/user_management/authenticate", "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", "GET api.factory.ai/api/organization/subscription/usage?useCache=true", ]) await FactorySessionStore.shared.clearSession() @@ -300,6 +302,385 @@ struct FactoryStatusProbeFetchTests { #expect(usage.secondary?.usedPercent == 10) } + @Test + func `uses bearer subject when auth profile omits user id`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + }, + "userProfile": { + "email": "user@example.com", + "role": "member" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + if url.path == "/api/organization/subscription/usage" { + guard URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "userId" && $0.value == "user_jwt" }) == true + else { + return Self.makeResponse( + url: url, + body: #"{"detail":"Must be manager to get usage for other users"}"#, + statusCode: 403) + } + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000, + "usedRatio": 0.10 + } + }, + "userId": "user_jwt" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let token = Self.makeJWT(payload: ["sub": "user_jwt"]) + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch(cookieHeaderOverride: "access-token=\(token); session=abc") + + #expect(snapshot.userId == "user_jwt") + #expect(snapshot.standardUserTokens == 100) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + "GET app.factory.ai/api/organization/subscription/usage?useCache=true&userId=user_jwt", + ]) + } + + @Test + func `falls back to legacy usage when billing limits request fails`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + throw URLError(.timedOut) + } + if url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000, + "usedRatio": 0.10 + }, + "premium": { + "userTokens": 20, + "totalAllowance": 100, + "usedRatio": 0.20 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch(cookieHeaderOverride: "access-token=test.jwt.token; session=abc") + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.tokenRateLimits == nil) + #expect(usage.primary?.usedPercent == 10) + #expect(usage.secondary?.usedPercent == 20) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + "GET app.factory.ai/api/organization/subscription/usage?useCache=true", + ]) + } + + @Test + func `falls back to legacy usage when billing limits rejects auth`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + return Self.makeResponse(url: url, body: "{}", statusCode: 403) + } + if url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000, + "usedRatio": 0.10 + }, + "premium": { + "userTokens": 20, + "totalAllowance": 100, + "usedRatio": 0.20 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch(cookieHeaderOverride: "access-token=test.jwt.token; session=abc") + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.tokenRateLimits == nil) + #expect(usage.primary?.usedPercent == 10) + #expect(usage.secondary?.usedPercent == 20) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + "GET app.factory.ai/api/organization/subscription/usage?useCache=true", + ]) + } + + @Test + func `uses token rate limits billing when core pool is absent`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + let body = """ + { + "usesTokenRateLimitsBilling": true, + "extraUsageBalanceCents": 2500, + "overagePreference": null, + "extraUsageAllowed": false, + "tokenRateLimitsRolloutEligible": true, + "limits": { + "standard": { + "fiveHour": { "usedPercent": 12, "secondsRemaining": 3600 }, + "weekly": { "usedPercent": 34, "secondsRemaining": 7200 }, + "monthly": { "usedPercent": 56, "secondsRemaining": 10800 } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.path == "/api/organization/subscription/usage" { + return Self.makeResponse(url: url, body: "{}", statusCode: 500) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch(cookieHeaderOverride: "access-token=test.jwt.token; session=abc") + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.tokenRateLimits != nil) + #expect(usage.primary?.usedPercent == 12) + #expect(usage.secondary?.usedPercent == 34) + #expect(usage.tertiary?.usedPercent == 56) + #expect(usage.extraRateWindows == nil) + #expect(usage.providerCost?.used == 25) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + ]) + } + + @Test + func `uses token rate limits billing when enabled`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + let body = """ + { + "usesTokenRateLimitsBilling": true, + "extraUsageBalanceCents": 2500, + "overagePreference": "core", + "extraUsageAllowed": true, + "tokenRateLimitsRolloutEligible": true, + "limits": { + "standard": { + "fiveHour": { "usedPercent": 12, "secondsRemaining": 3600 }, + "weekly": { "usedPercent": 34, "secondsRemaining": 7200 }, + "monthly": { "usedPercent": 56, "secondsRemaining": 10800 } + }, + "core": { + "fiveHour": { "usedPercent": 7, "secondsRemaining": 1800 }, + "weekly": { "usedPercent": 8, "secondsRemaining": 2800 }, + "monthly": { "usedPercent": 9, "secondsRemaining": 3800 } + } + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.path == "/api/organization/subscription/usage" { + return Self.makeResponse(url: url, body: "{}", statusCode: 500) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch(cookieHeaderOverride: "access-token=test.jwt.token; session=abc") + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.tokenRateLimits != nil) + #expect(usage.primary?.usedPercent == 12) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 34) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 56) + #expect(usage.extraRateWindows?.map(\.id) == ["factory-core-5h", "factory-core-7d", "factory-core-monthly"]) + #expect(usage.extraRateWindows?.first?.window.usedPercent == 7) + #expect(usage.providerCost?.used == 25) + #expect(usage.providerCost?.limit == 0) + #expect(usage.loginMethod(for: .factory) == "Factory Team - Team - Fallback: core") + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + ]) + } + private static func makeResponse( url: URL, body: String, @@ -313,6 +694,20 @@ struct FactoryStatusProbeFetchTests { return (response, Data(body.utf8)) } + private static func makeJWT(payload: [String: Any]) -> String { + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + let header = ["alg": "none", "typ": "JWT"] + let headerData = (try? JSONSerialization.data(withJSONObject: header)) ?? Data() + let payloadData = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data() + return "\(base64URL(headerData)).\(base64URL(payloadData))." + } + private static func requestTrace() -> [String] { FactoryStubURLProtocol.requests.compactMap { request in guard let url = request.url else { return nil } diff --git a/Tests/CodexBarTests/FactoryStatusProbeTests.swift b/Tests/CodexBarTests/FactoryStatusProbeTests.swift index 2d67b9f2f..f5481d074 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeTests.swift @@ -2,6 +2,18 @@ import Foundation import Testing @testable import CodexBarCore +struct FactoryProviderDescriptorTests { + @Test + func `descriptor keeps legacy labels by default`() { + let metadata = FactoryProviderDescriptor.descriptor.metadata + + #expect(metadata.sessionLabel == "Standard") + #expect(metadata.weeklyLabel == "Premium") + #expect(metadata.opusLabel == nil) + #expect(!metadata.supportsOpus) + } +} + struct FactoryStatusSnapshotTests { @Test func `maps usage snapshot windows and login method`() { @@ -105,6 +117,33 @@ struct FactoryStatusSnapshotTests { #expect(usage.primary?.usedPercent == 10) } + @Test + func `falls back to calculation when API ratio is zero but usage and allowance are present`() { + let snapshot = FactoryStatusSnapshot( + standardUserTokens: 5_826_293, + standardOrgTokens: 0, + standardAllowance: 20_000_000, + standardUsedRatio: 0, + premiumUserTokens: 0, + premiumOrgTokens: 0, + premiumAllowance: 0, + premiumUsedRatio: 0, + periodStart: nil, + periodEnd: nil, + planName: nil, + tier: nil, + organizationName: nil, + accountEmail: nil, + userId: nil, + rawJSON: nil) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent ?? 0 > 29) + #expect(usage.primary?.usedPercent ?? 0 < 30) + #expect(usage.secondary?.usedPercent == 0) + } + @Test func `falls back to calculation when API ratio missing`() { let snapshot = FactoryStatusSnapshot( diff --git a/Tests/CodexBarTests/Fixtures/models-dev-subset.json b/Tests/CodexBarTests/Fixtures/models-dev-subset.json new file mode 100644 index 000000000..312b0bab0 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/models-dev-subset.json @@ -0,0 +1,89 @@ +{ + "openai": { + "id": "openai", + "name": "OpenAI", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "name": "GPT-4o mini", + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.08 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + "shared-model": { + "id": "shared-model", + "name": "Shared model via OpenAI", + "cost": { + "input": 1, + "output": 2 + }, + "limit": { + "context": 128000 + } + } + } + }, + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "name": "Shared model via Anthropic", + "cost": { + "input": 3, + "output": 4 + }, + "limit": { + "context": 200000 + } + }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "name": "Vertex (Anthropic)", + "models": { + "claude-sonnet-4-6@default": { + "id": "claude-sonnet-4-6@default", + "name": "Claude Sonnet 4.6", + "cost": { + "input": 3.1, + "output": 15.1, + "cache_read": 0.31, + "cache_write": 3.76 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + } + } + } +} diff --git a/Tests/CodexBarTests/GeminiSourceLabelTests.swift b/Tests/CodexBarTests/GeminiSourceLabelTests.swift new file mode 100644 index 000000000..8ba8ee11c --- /dev/null +++ b/Tests/CodexBarTests/GeminiSourceLabelTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import CodexBarCore + +struct GeminiSourceLabelTests { + @Test + func `Gemini source label reflects OAuth backed API requests`() { + #expect(GeminiStatusFetchStrategy.sourceLabel == "oauth-api") + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 921ebe1e4..976ac72b9 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -118,6 +118,77 @@ struct GeminiStatusProbeAPITests { #expect(updated["access_token"] as? String == "new-token") } + @Test + func `refreshes when stored Gemini credentials only have refresh token`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: nil, + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousValue { + setenv("GEMINI_CLI_PATH", previousValue, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + guard auth == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountEmail == "user@example.com") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") + } + @Test func `refreshes expired token with nix share layout`() async throws { let env = try GeminiTestEnvironment() @@ -304,6 +375,79 @@ struct GeminiStatusProbeAPITests { #expect(updated["access_token"] as? String == "new-token") } + @Test + func `refreshes expired token with homebrew bundle layout`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let binURL = try env.writeFakeGeminiCLI(layout: .homebrewBundle) + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Paid") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") + } + @Test func `uses code assist project for quota`() async throws { let env = try GeminiTestEnvironment() @@ -373,6 +517,65 @@ struct GeminiStatusProbeAPITests { #expect(seenProject.get() == projectId) } + @Test + func `falls back to curl loader when URL session times out`() async throws { + let calls = LoaderCalls() + let url = try #require(URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) + let request = URLRequest(url: url) + let body = Data("{\"ok\":true}".utf8) + let loader = GeminiStatusProbe.dataLoaderWithCurlFallback( + primary: { _ in + calls.incrementPrimary() + throw URLError(.timedOut) + }, + fallback: { request in + calls.incrementFallback() + let (response, data) = GeminiAPITestHelpers.response( + url: request.url!.absoluteString, + status: 200, + body: body) + return (data, response) + }) + + let (loadedBody, loadedResponse) = try await loader(request) + let counts = calls.counts() + #expect(loadedBody == body) + #expect((loadedResponse as? HTTPURLResponse)?.statusCode == 200) + #expect(counts.primary == 1) + #expect(counts.fallback == 1) + } + + @Test + func `does not fall back to curl loader for non-timeout errors`() async throws { + let calls = LoaderCalls() + let url = try #require(URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) + let request = URLRequest(url: url) + let loader = GeminiStatusProbe.dataLoaderWithCurlFallback( + primary: { _ in + calls.incrementPrimary() + throw URLError(.cannotFindHost) + }, + fallback: { request in + calls.incrementFallback() + let (response, data) = GeminiAPITestHelpers.response( + url: request.url!.absoluteString, + status: 200, + body: Data()) + return (data, response) + }) + + do { + _ = try await loader(request) + Issue.record("Expected non-timeout URLSession error") + } catch let error as URLError { + #expect(error.code == .cannotFindHost) + } + + let counts = calls.counts() + #expect(counts.primary == 1) + #expect(counts.fallback == 0) + } + @Test func `fails refresh when O auth config missing`() async throws { let env = try GeminiTestEnvironment() @@ -542,4 +745,28 @@ struct GeminiStatusProbeAPITests { #expect(error as? GeminiStatusProbeError == expected) } } + + private final class LoaderCalls: @unchecked Sendable { + private let lock = NSLock() + private var primaryCount = 0 + private var fallbackCount = 0 + + func incrementPrimary() { + self.lock.lock() + self.primaryCount += 1 + self.lock.unlock() + } + + func incrementFallback() { + self.lock.lock() + self.fallbackCount += 1 + self.lock.unlock() + } + + func counts() -> (primary: Int, fallback: Int) { + self.lock.lock() + defer { self.lock.unlock() } + return (self.primaryCount, self.fallbackCount) + } + } } diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 0a371003e..6e7ce8d8a 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -5,18 +5,25 @@ struct GeminiTestEnvironment { case npmNested case nixShare case fnmBundle + case homebrewBundle } let homeURL: URL private let geminiDir: URL + private let antigravityDir: URL init() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let geminiDir = root.appendingPathComponent(".gemini") try FileManager.default.createDirectory(at: geminiDir, withIntermediateDirectories: true) + let antigravityDir = root + .appendingPathComponent(".codexbar") + .appendingPathComponent("antigravity") + try FileManager.default.createDirectory(at: antigravityDir, withIntermediateDirectories: true) self.homeURL = root self.geminiDir = geminiDir + self.antigravityDir = antigravityDir } func cleanup() { @@ -35,11 +42,16 @@ struct GeminiTestEnvironment { try data.write(to: self.geminiDir.appendingPathComponent("settings.json"), options: .atomic) } - func writeCredentials(accessToken: String, refreshToken: String?, expiry: Date, idToken: String?) throws { + func writeCredentials( + accessToken: String?, + refreshToken: String?, + expiry: Date, + idToken: String?) throws + { var payload: [String: Any] = [ - "access_token": accessToken, "expiry_date": expiry.timeIntervalSince1970 * 1000, ] + if let accessToken { payload["access_token"] = accessToken } if let refreshToken { payload["refresh_token"] = refreshToken } if let idToken { payload["id_token"] = idToken } let data = try JSONSerialization.data(withJSONObject: payload) @@ -53,6 +65,37 @@ struct GeminiTestEnvironment { return object as? [String: Any] ?? [:] } + func writeAntigravityCredentials( + accessToken: String, + refreshToken: String?, + expiry: Date, + idToken: String? = nil, + email: String? = nil, + projectID: String? = nil, + clientID: String? = nil, + clientSecret: String? = nil) throws + { + var payload: [String: Any] = [ + "access_token": accessToken, + "expiry_date": expiry.timeIntervalSince1970 * 1000, + ] + if let refreshToken { payload["refresh_token"] = refreshToken } + if let idToken { payload["id_token"] = idToken } + if let email { payload["email"] = email } + if let projectID { payload["project_id"] = projectID } + if let clientID { payload["client_id"] = clientID } + if let clientSecret { payload["client_secret"] = clientSecret } + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: self.antigravityDir.appendingPathComponent("oauth_creds.json"), options: .atomic) + } + + func readAntigravityCredentials() throws -> [String: Any] { + let url = self.antigravityDir.appendingPathComponent("oauth_creds.json") + let data = try Data(contentsOf: url) + let object = try JSONSerialization.jsonObject(with: data) + return object as? [String: Any] ?? [:] + } + func writeFakeGeminiCLI(includeOAuth: Bool = true, layout: GeminiCLILayout = .npmNested) throws -> URL { let base = self.homeURL.appendingPathComponent("gemini-cli") let binDir = base.appendingPathComponent("bin") @@ -189,7 +232,69 @@ struct GeminiTestEnvironment { withDestinationPath: "../lib/node_modules/@google/gemini-cli/bundle/gemini.js") return geminiBinary + + case .homebrewBundle: + return try self.writeFakeHomebrewGeminiCLI(base: base, includeOAuth: includeOAuth) + } + } + + private func writeFakeHomebrewGeminiCLI(base: URL, includeOAuth: Bool) throws -> URL { + let cellarRoot = base + .appendingPathComponent("Cellar") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("0.41.2") + let binDir = cellarRoot.appendingPathComponent("bin") + let packageRoot = cellarRoot + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + + let packageJSON = """ + { + "name": "@google/gemini-cli" + } + """ + try packageJSON.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + + let entry = bundleDir.appendingPathComponent("gemini.js") + try "#!/usr/bin/env node\nawait import('./gemini-HASH.js');\n".write( + to: entry, + atomically: true, + encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: entry.path) + try "export const run = () => {};\n".write( + to: bundleDir.appendingPathComponent("gemini-HASH.js"), + atomically: true, + encoding: .utf8) + + let chunkContent = if includeOAuth { + """ + var OAUTH_CLIENT_ID = "test-client-id"; + var OAUTH_CLIENT_SECRET = "test-client-secret"; + """ + } else { + "export const unrelated = true;\n" } + try chunkContent.write( + to: bundleDir.appendingPathComponent("chunk-OAUTH.js"), + atomically: true, + encoding: .utf8) + + let geminiBinary = binDir.appendingPathComponent("gemini") + try FileManager.default.createSymbolicLink( + atPath: geminiBinary.path, + withDestinationPath: "../libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js") + return geminiBinary } func writeFakeFnm( diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift new file mode 100644 index 000000000..926612729 --- /dev/null +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct GoogleWorkspaceStatusNetworkTests { + @Test + func `fetchWorkspaceStatus uses shared client`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let body = Data(#""" + [ + { + "begin": "2026-05-10T10:00:00+00:00", + "end": null, + "affected_products": [ + {"title": "Gemini", "id": "npdyhgECDJ6tB66MxXyo"} + ], + "most_recent_update": { + "when": "2026-05-10T10:15:00+00:00", + "status": "SERVICE_OUTAGE", + "text": "**Summary**\nGemini API error.\n" + } + } + ] + """#.utf8) + return (body, response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport) + + #expect(status.indicator == .critical) + #expect(status.description == "Gemini API error.") + let requests = await transport.requests() + #expect(requests.count == 1) + #expect(requests.first?.url?.host == "www.google.com") + } +} diff --git a/Tests/CodexBarTests/GrokAuthTests.swift b/Tests/CodexBarTests/GrokAuthTests.swift new file mode 100644 index 000000000..a2ed52e55 --- /dev/null +++ b/Tests/CodexBarTests/GrokAuthTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokAuthTests { + @Test + func `parses OIDC SuperGrok entry`() throws { + let json = #""" + { + "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": { + "key": "secret-access-token-123", + "auth_mode": "oidc", + "create_time": "2026-05-15T13:31:33.384327Z", + "user_id": "user-uuid", + "email": "user@example.com", + "first_name": "Ada", + "last_name": "Lovelace", + "team_id": "team-uuid", + "refresh_token": "refresh-secret", + "expires_at": "2026-05-22T19:31:33.384327Z", + "oidc_issuer": "https://auth.x.ai", + "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + + #expect(creds.accessToken == "secret-access-token-123") + #expect(creds.refreshToken == "refresh-secret") + #expect(creds.email == "user@example.com") + #expect(creds.teamId == "team-uuid") + #expect(creds.authMode == "oidc") + #expect(creds.displayName == "Ada Lovelace") + #expect(creds.loginMethod == "SuperGrok") + #expect(creds.expiresAt != nil) + } + + @Test + func `falls back to legacy session scope when OIDC absent`() throws { + let json = #""" + { + "https://accounts.x.ai/sign-in": { + "key": "legacy-token", + "auth_mode": "session", + "email": "legacy@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "legacy-token") + #expect(creds.email == "legacy@example.com") + #expect(creds.loginMethod == "session") + } + + @Test + func `throws missingTokens when key absent`() { + let json = #"{"https://auth.x.ai::abc": {"auth_mode": "oidc"}}"# + let data = Data(json.utf8) + #expect(throws: GrokCredentialsError.self) { + _ = try GrokCredentialsStore.parse(data: data) + } + } + + @Test + func `throws decodeFailed when JSON is invalid`() { + let data = Data("not-json".utf8) + #expect(throws: GrokCredentialsError.self) { + _ = try GrokCredentialsStore.parse(data: data) + } + } + + @Test + func `isExpired reflects past expires_at`() throws { + // Past expiry + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let past = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + #expect(past.isExpired == true) + + // Future expiry + let futureJson = #""" + { + "https://auth.x.ai::client": { + "key": "fresh-token", + "expires_at": "2099-01-01T00:00:00Z" + } + } + """# + let future = try GrokCredentialsStore.parse(data: Data(futureJson.utf8)) + #expect(future.isExpired == false) + + // Missing expires_at — treated as non-expired so we never spuriously lock + // out clients whose auth.json shape predates this field. + let noExpiryJson = #""" + { + "https://auth.x.ai::client": { + "key": "ageless-token" + } + } + """# + let noExpiry = try GrokCredentialsStore.parse(data: Data(noExpiryJson.utf8)) + #expect(noExpiry.isExpired == false) + } + + @Test + func `expired credentials are preserved when billing succeeds`() throws { + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "email": "grok@example.com", + "team_id": "team_123", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let expired = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + let billing = try JSONDecoder().decode(GrokBillingResponse.self, from: Data(#"{}"#.utf8)) + let webBilling = GrokWebBillingSnapshot( + usedPercent: 42, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000)) + + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: nil) == nil) + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: billing)? + .email == "grok@example.com") + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: nil, webBilling: webBilling)? + .email == "grok@example.com") + } + + @Test + func `remote auth failures surface even with fresh local credentials`() { + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.requestFailed(401, "unauthorized"))) + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.requestFailed(403, "forbidden"))) + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.rpcFailed(16, "token expired"))) + #expect(!GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.parseFailed)) + } + + @Test + func `falls back to legacy when OIDC entry has no key`() throws { + // A stale/partial OIDC record must not shadow a healthy legacy session. + let json = #""" + { + "https://auth.x.ai::stale-client": { + "auth_mode": "oidc", + "email": "stale@example.com" + }, + "https://accounts.x.ai/sign-in": { + "key": "healthy-legacy-token", + "auth_mode": "session", + "email": "healthy@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "healthy-legacy-token") + #expect(creds.email == "healthy@example.com") + } + + @Test + func `prefers OIDC entry over legacy session when both present`() throws { + let json = #""" + { + "https://accounts.x.ai/sign-in": { + "key": "legacy-should-not-win", + "auth_mode": "session" + }, + "https://auth.x.ai::client-id": { + "key": "oidc-wins", + "auth_mode": "oidc", + "email": "preferred@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "oidc-wins") + #expect(creds.email == "preferred@example.com") + } +} diff --git a/Tests/CodexBarTests/GrokBillingResponseTests.swift b/Tests/CodexBarTests/GrokBillingResponseTests.swift new file mode 100644 index 000000000..5d038a50c --- /dev/null +++ b/Tests/CodexBarTests/GrokBillingResponseTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokBillingResponseTests { + @Test + func `decodes full BillingConfigResponse and computes percent`() throws { + let json = #""" + { + "billingCycle": { + "billingPeriodStart": "2026-05-01T00:00:00Z", + "billingPeriodEnd": "2026-06-01T00:00:00Z" + }, + "monthlyLimit": { "val": 99900 }, + "onDemandCap": { "val": 0 }, + "on_demand_enabled": false, + "disabledByConfig": false, + "usage": { + "includedUsed": { "val": 49950 }, + "onDemandUsed": { "val": 0 }, + "totalUsed": { "val": 49950 } + } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + + #expect(response.monthlyLimit?.val == 99900) + #expect(response.usage?.totalUsed?.val == 49950) + #expect(response.monthlyUsedPercent == 50.0) + #expect(response.billingPeriodEndDate != nil) + } + + @Test + func `monthlyUsedPercent returns nil when limit missing`() throws { + let json = #""" + { + "usage": { "totalUsed": { "val": 100 } } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.monthlyUsedPercent == nil) + } + + @Test + func `monthlyUsedPercent clamps over-100 usage`() throws { + let json = #""" + { + "monthlyLimit": { "val": 1000 }, + "usage": { "totalUsed": { "val": 5000 } } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.monthlyUsedPercent == 100.0) + } + + @Test + func `handles missing optional fields gracefully`() throws { + let json = #"{}"# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.billingCycle == nil) + #expect(response.monthlyLimit == nil) + #expect(response.monthlyUsedPercent == nil) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift new file mode 100644 index 000000000..3122e859d --- /dev/null +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -0,0 +1,619 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokWebBillingFetcherTests { + private final class AttemptCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.value += 1 + return self.value + } + + func current() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `provider exposes cli and web source modes`() { + #expect(GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .web]) + } + + @Test + func `cli runtime does not import browser cookies unless explicitly enabled`() { + #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) + #expect(!GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:])) + #expect(GrokWebFetchStrategy.canImportBrowserCookies( + runtime: .cli, + env: ["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT": "1"])) + } + + @Test + func `web strategy tries later browser session when first cookie is stale`() async throws { + let stale = try #require(Self.cookie(name: "sso", value: "stale")) + let valid = try #require(Self.cookie(name: "sso", value: "valid")) + let sessions = [ + GrokCookieImporter.SessionInfo(cookies: [stale], sourceLabel: "Chrome Profile 1"), + GrokCookieImporter.SessionInfo(cookies: [valid], sourceLabel: "Chrome Profile 2"), + ] + var attemptedHeaders: [String] = [] + + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession(sessions) { cookieHeader in + attemptedHeaders.append(cookieHeader) + guard cookieHeader.contains("valid") else { + throw GrokWebBillingError.requestFailed(401, "stale") + } + return GrokWebBillingSnapshot( + usedPercent: 12, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000)) + } + + #expect(attemptedHeaders == ["sso=stale", "sso=valid"]) + #expect(result.0.usedPercent == 12) + #expect(result.1 == "Chrome Profile 2") + } + + @Test + func `cookie authenticated web billing does not reuse auth file identity`() { + #expect(GrokWebFetchStrategy.credentialsForWebBillingSnapshot( + credentials: Self.credentials, + authenticatedByAuthFile: false) == nil) + #expect(GrokWebFetchStrategy.credentialsForWebBillingSnapshot( + credentials: Self.credentials, + authenticatedByAuthFile: true)? + .email == "grok@example.com") + } + + @Test + func `parses grok grpc web billing frame`() throws { + let reset = UInt64(1_800_000_000) + let payload = Self.protobufPayload(usedPercent: 42.5, resetEpoch: reset) + let data = Self.grpcFrame(payload) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_799_000_000)) + + #expect(snapshot.usedPercent == 42.5) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `ignores grpc web trailer frames`() { + let payload = Self.protobufPayload(usedPercent: 12.25, resetEpoch: 1_800_000_001) + let trailer = Data("grpc-status: 0\r\n".utf8) + let data = Self.grpcFrame(payload) + Self.grpcFrame(trailer, flags: 0x80) + + let frames = GrokWebBillingFetcher.grpcWebDataFrames(from: data) + + #expect(frames == [payload]) + } + + @Test + func `web fetch turns grpc unauthenticated trailer into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let body = Self.grpcFrame(Data("grpc-status: 16\r\ngrpc-message: token%20expired\r\n".utf8), flags: 0x80) + + #expect(GrokWebBillingFetcher.grpcWebTrailerFields(from: body)["grpc-status"] == "16") + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, body) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `web fetch turns grpc unauthenticated headers into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Content-Type": "application/grpc-web+proto", + "grpc-status": "16", + "grpc-message": "Invalid%20bearer%20token.", + ])! + return (response, Data()) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `rejects reset only billing because it cannot render usage`() { + var payload = Data() + payload.append(0x10) // field 2, varint reset timestamp + payload.append(contentsOf: Self.varint(1_800_000_001)) + + #expect { + _ = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.grpcFrame(payload)) + } throws: { error in + guard case GrokWebBillingError.parseFailed = error else { return false } + return true + } + } + + @Test + func `parses grok no usage yet billing response as zero percent`() throws { + let data = Data([ + 0x00, 0x00, 0x00, 0x00, 0x37, 0x0A, 0x35, 0x12, + 0x00, 0x1A, 0x00, 0x22, 0x06, 0x08, 0x80, 0xDA, + 0xCF, 0xCF, 0x06, 0x2A, 0x06, 0x08, 0x80, 0x97, + 0xF3, 0xD0, 0x06, 0x32, 0x09, 0x0A, 0x05, 0x08, + 0xEA, 0x0F, 0x10, 0x04, 0x12, 0x00, 0x32, 0x09, + 0x0A, 0x05, 0x08, 0xEA, 0x0F, 0x10, 0x03, 0x12, + 0x00, 0x32, 0x09, 0x0A, 0x05, 0x08, 0xEA, 0x0F, + 0x10, 0x02, 0x12, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x0F, 0x67, 0x72, 0x70, 0x63, 0x2D, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x3A, 0x30, 0x0D, 0x0A, + ]) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_768_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_780_272_000)) + } + + @Test + func `uses billing field one instead of earlier unrelated float`() throws { + var payload = Data() + payload.append(0x4D) // field 9, fixed32 unrelated in-range float + var unrelatedBits = Float(7).bitPattern.littleEndian + withUnsafeBytes(of: &unrelatedBits) { payload.append(contentsOf: $0) } + payload.append(0x0D) // field 1, fixed32 billing usage percent + var usageBits = Float(42).bitPattern.littleEndian + withUnsafeBytes(of: &usageBits) { payload.append(contentsOf: $0) } + payload.append(0x10) // field 2, varint reset timestamp + payload.append(contentsOf: Self.varint(1_800_000_001)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.grpcFrame(payload)) + + #expect(snapshot.usedPercent == 42) + } + + @Test + func `chooses future billing end instead of recent billing start`() throws { + let recentStart = UInt64(1_800_000_000) + let billingEnd = UInt64(1_802_592_000) + var payload = Data() + payload.append(0x0D) // field 1, fixed32 usage percent + var percentBits = Float(33).bitPattern.littleEndian + withUnsafeBytes(of: &percentBits) { payload.append(contentsOf: $0) } + payload.append(0x10) // field 2, varint billing start + payload.append(contentsOf: Self.varint(recentStart)) + payload.append(0x18) // field 3, varint billing end + payload.append(contentsOf: Self.varint(billingEnd)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.grpcFrame(payload), + now: Date(timeIntervalSince1970: TimeInterval(recentStart + 1800))) + + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(billingEnd))) + } + + @Test + func `web fetch posts grpc web request with bearer token`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let reset = UInt64(1_800_000_002) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url == endpoint) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token-123") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://grok.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://grok.com/?_s=usage") + #expect(request.value(forHTTPHeaderField: "Accept") == "*/*") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/grpc-web+proto") + #expect(request.value(forHTTPHeaderField: "x-grpc-web") == "1") + #expect(request.timeoutInterval == 15) + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + let body = Self.grpcFrame(Self.protobufPayload(usedPercent: 55.5, resetEpoch: reset)) + return (response, body) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(GrokWebBillingStubURLProtocol.requests.count == 1) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x00])]) + #expect(snapshot.usedPercent == 55.5) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `web fetch retries transient grpc timeout once`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let reset = UInt64(1_800_000_005) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + let body = Self.grpcFrame( + Data("grpc-status: 1\r\ngrpc-message: Timeout%20expired\r\n".utf8), + flags: 0x80) + return (response, body) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: reset))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(GrokWebBillingStubURLProtocol.requests.count == 2) + #expect(snapshot.usedPercent == 25) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `web fetch retries grpc deadline exceeded without message`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + return (response, Self.grpcFrame(Data("grpc-status: 4\r\n".utf8), flags: 0x80)) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: 1_800_000_005))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(snapshot.usedPercent == 25) + } + + @Test + func `web fetch retries HTTP gateway timeout once`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let statusCode = attempt == 1 ? 504 : 200 + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + return (response, Data("gateway timeout".utf8)) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: 1_800_000_005))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(snapshot.usedPercent == 25) + } + + @Test + func `web fetch can authenticate with browser cookies`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session; sso-rw=session") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(request.value(forHTTPHeaderField: "x-user-agent") == "connect-es/2.1.1") + let response = HTTPURLResponse( + url: endpoint, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + let body = Self.grpcFrame(Self.protobufPayload(usedPercent: 9, resetEpoch: 1_800_000_004)) + return (response, body) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + cookieHeader: "sso=session; sso-rw=session", + session: session, + endpoint: endpoint) + + #expect(snapshot.usedPercent == 9) + } + + @Test + func `web fetch turns unauthorized response into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: ["Content-Type": "text/plain"])! + return (response, Data("unauthorized".utf8)) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `usage snapshot maps web billing when cli billing is absent`() { + let snapshot = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot( + usedPercent: 67.25, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + credentials: Self.credentials, + localSummary: nil, + cliVersion: nil, + updatedAt: Date(timeIntervalSince1970: 1_799_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 67.25) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_800_000_003)) + #expect(usage.accountEmail(for: .grok) == "grok@example.com") + #expect(usage.loginMethod(for: .grok) == "SuperGrok") + } + + private static let credentials = GrokCredentials( + accessToken: "token-123", + refreshToken: "refresh-123", + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: "user-123", + email: "grok@example.com", + firstName: "G", + lastName: "Rok", + teamId: "team-123", + oidcIssuer: "https://auth.x.ai", + oidcClientId: "client", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + createTime: Date(timeIntervalSince1970: 1_799_000_000)) + + private static func protobufPayload(usedPercent: Float, resetEpoch: UInt64) -> Data { + var data = Data() + data.append(0x0D) // field 1, fixed32 + var percentBits = usedPercent.bitPattern.littleEndian + withUnsafeBytes(of: &percentBits) { data.append(contentsOf: $0) } + data.append(0x10) // field 2, varint + data.append(contentsOf: Self.varint(resetEpoch)) + return data + } + + private static func grpcFrame(_ payload: Data, flags: UInt8 = 0x00) -> Data { + var data = Data([flags]) + let length = UInt32(payload.count).bigEndian + withUnsafeBytes(of: length) { data.append(contentsOf: $0) } + data.append(payload) + return data + } + + private static func varint(_ value: UInt64) -> [UInt8] { + var remaining = value + var bytes: [UInt8] = [] + repeat { + var byte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { byte |= 0x80 } + bytes.append(byte) + } while remaining != 0 + return bytes + } + + private static func cookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "grok.com", + .path: "/", + .name: name, + .value: value, + ]) + } +} + +final class GrokWebBillingStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var requestBodies: [Data?] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + Self.requestBodies.append(Self.readBody(from: self.request)) + 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() {} + + private static func readBody(from request: URLRequest) -> Data? { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count > 0 { + data.append(buffer, count: count) + } else { + break + } + } + return data + } +} diff --git a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift index cd61a0d46..469613071 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift @@ -343,4 +343,45 @@ extension HistoricalUsagePaceTests { #expect(computed != nil) #expect(abs((computed?.deltaPercent ?? 0) - (linear?.deltaPercent ?? 0)) < 0.001) } + + @MainActor + @Test + func `usage store computes linear pace for providers with quota windows`() throws { + let suite = "HistoricalUsagePaceTests-generic-provider-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: nil) + + let pace = store.weeklyPace(provider: .zai, window: window, now: now) + + #expect(pace != nil) + #expect(abs((pace?.deltaPercent ?? 0) - (40 - (3.0 / 7.0 * 100.0))) < 0.001) + } + + @MainActor + @Test + func `usage store returns nil pace when generic window lacks explicit duration`() throws { + let suite = "HistoricalUsagePaceTests-no-window-minutes-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 40, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: nil) + + let pace = store.weeklyPace(provider: .factory, window: window, now: now) + + #expect(pace == nil) + } } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift index 7c32b1974..9cfa9ede1 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift @@ -283,7 +283,7 @@ extension HistoricalUsagePaceTests { windowMinutes: record.windowMinutes) }, to: fileURL) - let expectedResetAt = fixtureResetAt.addingTimeInterval(dateShift) + let expectedResetAt = Self.normalizeReset(fixtureResetAt.addingTimeInterval(dateShift)) let dataset = await store.loadCodexDataset( canonicalAccountKey: providerAccountKey, diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index 7e1a3fb01..32ff81233 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -94,7 +94,7 @@ extension HistoricalUsagePaceTests { } static func normalizeReset(_ value: Date) -> Date { - let bucket = 60.0 + let bucket = 5 * 60.0 let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket return Date(timeIntervalSinceReferenceDate: rounded) } @@ -236,7 +236,7 @@ extension HistoricalUsagePaceTests { static func waitForHistoricalRecords( at fileURL: URL, minimumCount: Int, - timeoutMilliseconds: UInt64 = 2000) async throws -> [HistoricalUsageRecord] + timeoutMilliseconds: UInt64 = 10000) async throws -> [HistoricalUsageRecord] { let deadline = ContinuousClock.now + .milliseconds(timeoutMilliseconds) while ContinuousClock.now < deadline { diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index a8a2200b6..63cecbfe4 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -285,6 +285,44 @@ struct HistoricalUsagePaceTests { #expect(Self.datasetCurveSignature(first) == Self.datasetCurveSignature(second)) } + @Test + func `history store backfill is idempotent across minute scale reset jitter`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let now = Date(timeIntervalSince1970: 1_770_000_000) + let windowMinutes = 10080 + let canonicalReset = Self.normalizeReset(now.addingTimeInterval(2 * 24 * 60 * 60)) + let firstWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(-90), + resetDescription: nil) + let secondWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(90), + resetDescription: nil) + + let breakdown = Self.syntheticBreakdown(endingAt: now, days: 35, dailyCredits: 10) + let first = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: firstWindow, + now: now, + accountKey: nil) + let recordsAfterFirst = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + let second = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: secondWindow, + now: now, + accountKey: nil) + let recordsAfterSecond = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + + #expect((first?.weeks.count ?? 0) >= 3) + #expect(first?.weeks.count == second?.weeks.count) + #expect(recordsAfterSecond.count == recordsAfterFirst.count) + #expect(Self.datasetCurveSignature(first) == Self.datasetCurveSignature(second)) + } + @Test func `history store backfill fills incomplete existing week`() async { let fileURL = Self.makeTempURL() @@ -358,7 +396,7 @@ struct HistoricalUsagePaceTests { let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 let duration = TimeInterval(windowMinutes) * 60 - let canonicalReset = Date(timeIntervalSince1970: 1_770_000_000) + let canonicalReset = Self.normalizeReset(Date(timeIntervalSince1970: 1_770_000_000)) let windowStart = canonicalReset.addingTimeInterval(-duration) let samples: [(u: Double, used: Double)] = [ @@ -370,7 +408,7 @@ struct HistoricalUsagePaceTests { (0.98, 95), ] for (index, sample) in samples.enumerated() { - let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -20 : 20) + let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -100 : 100) _ = await store.recordCodexWeekly( window: RateWindow( usedPercent: sample.used, @@ -400,7 +438,7 @@ struct HistoricalUsagePaceTests { window: RateWindow( usedPercent: 35, windowMinutes: windowMinutes, - resetsAt: targetReset.addingTimeInterval(30), + resetsAt: targetReset.addingTimeInterval(120), resetDescription: nil), sampledAt: targetStart.addingTimeInterval(duration * 0.5), accountKey: nil) diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 12145ea6a..0e4152394 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -9,6 +9,25 @@ struct KeychainCacheStoreTests { let storedAt: Date } + @Test + func `tests suppress real keychain access by default`() { + guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } + + #expect(KeychainCacheStore.canUseRealKeychainForTesting == false) + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "implicit", storedAt: Date(timeIntervalSince1970: 0)) + + KeychainCacheStore.store(key: key, entry: entry) + defer { KeychainCacheStore.clear(key: key) } + + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected implicit test cache entry") + } + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -69,6 +88,48 @@ struct KeychainCacheStoreTests { } } + @Test + func `clear reports whether an entry was removed`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "gone", storedAt: Date(timeIntervalSince1970: 0)) + KeychainCacheStore.store(key: key, entry: entry) + + #expect(KeychainCacheStore.clear(key: key) == true) + #expect(KeychainCacheStore.clear(key: key) == false) + } + + @Test + func `keys lists only matching category for current service`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let serviceA = "cache-keys-a-\(UUID().uuidString)" + let serviceB = "cache-keys-b-\(UUID().uuidString)" + let cookieA = KeychainCacheStore.Key(category: "cookie", identifier: "codex") + let scopedCookieA = KeychainCacheStore.Key(category: "cookie", identifier: "codex.managed.account") + let oauthA = KeychainCacheStore.Key(category: "oauth", identifier: "codex") + let cookieB = KeychainCacheStore.Key(category: "cookie", identifier: "claude") + let entry = TestEntry(value: "value", storedAt: Date(timeIntervalSince1970: 0)) + + KeychainCacheStore.withServiceOverrideForTesting(serviceA) { + KeychainCacheStore.store(key: cookieA, entry: entry) + KeychainCacheStore.store(key: scopedCookieA, entry: entry) + KeychainCacheStore.store(key: oauthA, entry: entry) + } + KeychainCacheStore.withServiceOverrideForTesting(serviceB) { + KeychainCacheStore.store(key: cookieB, entry: entry) + } + + let keys = KeychainCacheStore.withServiceOverrideForTesting(serviceA) { + KeychainCacheStore.keys(category: "cookie") + } + + #expect(keys == [cookieA, scopedCookieA]) + } + #if os(macOS) @Test func `interaction not allowed is treated as temporarily unavailable`() { @@ -111,5 +172,28 @@ struct KeychainCacheStoreTests { #expect(Bool(false), "Expected override not to mutate test store") } } + + @Test + func `cache ACL trusts bundled app and CLI helper`() { + let root = URL(fileURLWithPath: "/Applications/CodexBar.app") + let executable = root.appendingPathComponent("Contents/MacOS/CodexBar") + let helper = root.appendingPathComponent("Contents/Helpers/CodexBarCLI") + let existing = Set([ + root.path, + executable.path, + helper.path, + ]) + + let paths = KeychainCacheStore.trustedApplicationPathsForCacheAccess( + bundleURL: root, + executableURL: executable, + fileExists: { existing.contains($0) }) + + #expect(paths == [ + root.path, + helper.path, + executable.path, + ]) + } #endif } diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift new file mode 100644 index 000000000..58591543f --- /dev/null +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +import Darwin +import LocalAuthentication +import Security + +struct KeychainNoUIQueryTests { + private func resolveSecurityUIFailValue() -> String { + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" + } + + @Test + func `apply sets non interactive context and UI fail policy`() { + var query: [String: Any] = [:] + + KeychainNoUIQuery.apply(to: &query) + + let context = query[kSecUseAuthenticationContext as String] as? LAContext + #expect(context != nil) + #expect(context?.interactionNotAllowed == true) + + let uiPolicy = query[kSecUseAuthenticationUI as String] as? String + #expect(uiPolicy == self.resolveSecurityUIFailValue()) + #expect(uiPolicy == (KeychainNoUIQuery.uiFailPolicyForTesting() as String)) + #expect(uiPolicy != "kSecUseAuthenticationUIFail") + } + + @Test + func `preflight query is strictly non interactive and does not request secret data`() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "test.service", + account: "test.account") + + #expect(query[kSecReturnData as String] == nil) + #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) + #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) + } + + @Test + func `preflight query executes without invalid UI policy`() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "codexbar.keychain.noui.\(UUID().uuidString)", + account: nil) + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) + } +} +#endif diff --git a/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift b/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift new file mode 100644 index 000000000..371bc3bfb --- /dev/null +++ b/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloBearerTokenResolverTests { + private func writeAuthFile(_ json: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-resolver-tests-\(UUID().uuidString)", isDirectory: true) + let kiloDir = directory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("kilo", isDirectory: true) + try FileManager.default.createDirectory(at: kiloDir, withIntermediateDirectories: true) + let authURL = kiloDir.appendingPathComponent("auth.json", isDirectory: false) + try json.write(to: authURL, atomically: true, encoding: .utf8) + return directory + } + + @Test + func `api mode uses provided apiKey`() throws { + let resolved = try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: "kilo_abc", + environment: [:]) + #expect(resolved.token == "kilo_abc") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `api mode falls back to KILO_API_KEY env var when apiKey is empty`() throws { + let resolved = try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: nil, + environment: ["KILO_API_KEY": "kilo_from_env"]) + #expect(resolved.token == "kilo_from_env") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `api mode throws missingCredentials when nothing available`() { + #expect(throws: KiloUsageError.missingCredentials) { + try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: nil, + environment: [:]) + } + } + + @Test + func `cli mode reads token from auth.json`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-token" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": home.path]) + #expect(resolved.token == "cli-token") + #expect(resolved.sourceLabel == "cli") + } + + @Test + func `cli mode throws cliSessionMissing when auth.json missing`() { + let nonexistentHome = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-no-such-home-\(UUID().uuidString)", isDirectory: true) + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": nonexistentHome.path]) + } + } + + @Test + func `cli mode throws cliSessionInvalid for malformed JSON`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": home.path]) + } + } + + @Test + func `auto mode prefers API key when available`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-token" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: "kilo_api", + environment: ["HOME": home.path]) + #expect(resolved.token == "kilo_api") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `auto mode falls back to CLI when API key missing`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-fallback" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: nil, + environment: ["HOME": home.path]) + #expect(resolved.token == "cli-fallback") + #expect(resolved.sourceLabel == "cli") + } + + @Test + func `auto mode surfaces CLI error when neither path available`() { + let nonexistentHome = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-no-such-home-\(UUID().uuidString)", isDirectory: true) + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: nil, + environment: ["HOME": nonexistentHome.path]) + } + } +} diff --git a/Tests/CodexBarTests/KiloOrganizationTests.swift b/Tests/CodexBarTests/KiloOrganizationTests.swift new file mode 100644 index 000000000..7b12e273e --- /dev/null +++ b/Tests/CodexBarTests/KiloOrganizationTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloOrganizationTests { + @Test + func `decodes from canonical Kilo profile payload`() throws { + let json = #""" + { "id": "org_123", "name": "Acme Corp", "role": "owner" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.id == "org_123") + #expect(org.name == "Acme Corp") + #expect(org.role == "owner") + } + + @Test + func `decodes when role missing`() throws { + let json = #""" + { "id": "org_xyz", "name": "No Role Org" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.role == nil) + } + + @Test + func `equality covers all stored fields`() { + let a = KiloOrganization(id: "org_1", name: "A", role: "member") + let b = KiloOrganization(id: "org_1", name: "A", role: "member") + let differentRole = KiloOrganization(id: "org_1", name: "A", role: "owner") + #expect(a == b) + #expect(a != differentRole) + } +} + +struct KiloUsageScopeTests { + @Test + func `personal scope identifier is stable`() { + let scope: KiloUsageScope = .personal + #expect(scope.scopeIdentifier == "personal") + } + + @Test + func `organization scope identifier prefixes id`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.scopeIdentifier == "org:org_42") + } + + @Test + func `organizationID is nil for personal`() { + #expect(KiloUsageScope.personal.organizationID == nil) + } + + @Test + func `organizationID returns id for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.organizationID == "org_42") + } + + @Test + func `displayName falls back to Personal for personal`() { + #expect(KiloUsageScope.personal.displayName == "Personal") + } + + @Test + func `displayName uses org name for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.displayName == "Acme") + } +} diff --git a/Tests/CodexBarTests/KiloSettingsStoreTests.swift b/Tests/CodexBarTests/KiloSettingsStoreTests.swift new file mode 100644 index 000000000..c5156a854 --- /dev/null +++ b/Tests/CodexBarTests/KiloSettingsStoreTests.swift @@ -0,0 +1,57 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct KiloSettingsStoreTests { + private func makeSettings() throws -> SettingsStore { + let suite = "KiloSettingsStoreTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `defaults to empty known organizations and empty enabled ids`() throws { + let settings = try self.makeSettings() + #expect(settings.kiloKnownOrganizations.isEmpty) + #expect(settings.kiloEnabledOrganizationIDs.isEmpty) + } + + @Test + func `setting known organizations persists them`() throws { + let settings = try self.makeSettings() + let orgs = [ + KiloOrganization(id: "org_1", name: "Alpha", role: "owner"), + KiloOrganization(id: "org_2", name: "Beta", role: "member"), + ] + settings.kiloKnownOrganizations = orgs + #expect(settings.kiloKnownOrganizations == orgs) + } + + @Test + func `setting enabled org ids persists them`() throws { + let settings = try self.makeSettings() + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + #expect(settings.kiloEnabledOrganizationIDs == ["org_1", "org_2"]) + } + + @Test + func `setKiloKnownOrganizations prunes stale enabled ids`() throws { + let settings = try self.makeSettings() + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_1", name: "Alpha", role: nil), + KiloOrganization(id: "org_2", name: "Beta", role: nil), + ] + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + settings.setKiloKnownOrganizationsPruningEnabled( + [KiloOrganization(id: "org_2", name: "Beta", role: nil)]) + #expect(settings.kiloKnownOrganizations.map(\.id) == ["org_2"]) + #expect(settings.kiloEnabledOrganizationIDs == ["org_2"]) + } +} diff --git a/Tests/CodexBarTests/KiloUsageFetcherTests.swift b/Tests/CodexBarTests/KiloUsageFetcherTests.swift index 9253baf23..bd2828c50 100644 --- a/Tests/CodexBarTests/KiloUsageFetcherTests.swift +++ b/Tests/CodexBarTests/KiloUsageFetcherTests.swift @@ -700,6 +700,78 @@ struct KiloUsageFetcherTests { context: self.makeContext(sourceMode: .api))) } + @Test + func `request builder adds org header for organization scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .organization(id: "org_42", name: "Acme")) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == "org_42") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `request builder omits org header for personal scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .personal) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `parseOrganizations decodes tRPC array shape`() throws { + let json = #""" + [ + { + "result": { + "data": { + "json": [ + { "id": "org_1", "name": "Alpha", "role": "owner" }, + { "id": "org_2", "name": "Beta", "role": "member" } + ] + } + } + } + ] + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 2) + #expect(orgs[0].id == "org_1") + #expect(orgs[0].name == "Alpha") + #expect(orgs[0].role == "owner") + #expect(orgs[1].id == "org_2") + #expect(orgs[1].role == "member") + } + + @Test + func `parseOrganizations decodes profile REST shape`() throws { + let json = #""" + { + "user": { "email": "test@example.com" }, + "organizations": [ + { "id": "org_42", "name": "Gamma" } + ] + } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 1) + #expect(orgs[0].id == "org_42") + #expect(orgs[0].role == nil) + } + + @Test + func `parseOrganizations returns empty for no orgs`() throws { + let json = #""" + { "user": { "email": "x@y" }, "organizations": [] } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.isEmpty) + } + private func makeTemporaryHomeDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift index c6ec6eda4..9b549bfbf 100644 --- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift +++ b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift @@ -85,4 +85,17 @@ struct KimiK2UsageFetcherTests { return message == "Root JSON is not an object." } } + + @Test + func `converts api key credits into text only snapshot`() { + let usage = KimiK2UsageSummary( + consumed: 10, + remaining: 25, + averageTokens: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .kimik2) + #expect(usage.identity?.loginMethod == "Credits: 25 left") + } } diff --git a/Tests/CodexBarTests/KiroMenuCardModelTests.swift b/Tests/CodexBarTests/KiroMenuCardModelTests.swift new file mode 100644 index 000000000..af7a2885a --- /dev/null +++ b/Tests/CodexBarTests/KiroMenuCardModelTests.swift @@ -0,0 +1,108 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct KiroMenuCardModelTests { + @Test + func `kiro model shows account plan credits bonus and overages`() throws { + let now = Date() + let snapshot = KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Enabled billed at $0.04 per request", + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: now.addingTimeInterval(3600), + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + 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.email == "person@example.com") + #expect(model.planText == "Kiro Free") + #expect(model.metrics.map(\.title) == ["Credits", "Bonus"]) + #expect(model.metrics.first?.detailLeftText == "49.83 of 50 credits left") + #expect(model.metrics.dropFirst().first?.detailLeftText == "1954.47 of 2000 bonus credits left") + #expect(model.usageNotes.contains("Auth: Google")) + #expect(model.usageNotes.contains("Overages: Enabled billed at $0.04 per request")) + #expect(model.usageNotes.contains("Overage usage: 40.29 credits")) + #expect(model.usageNotes.contains("Overage cost: $1.61")) + #expect(model.usageNotes.contains { $0.localizedCaseInsensitiveContains("Context window") } == false) + } + + @Test + func `kiro model hides overage spend when overages are disabled`() throws { + let now = Date() + let snapshot = KiroUsageSnapshot( + planName: "KIRO FREE", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + overagesStatus: "Disabled", + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + resetsAt: nil, + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + 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.usageNotes.contains("Overages: Disabled")) + #expect(model.usageNotes.contains("Overage usage: 40.29 credits") == false) + #expect(model.usageNotes.contains("Overage cost: $1.61") == false) + } +} diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 0ef6e01d4..f56ab0b39 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -17,6 +17,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") #expect(snapshot.creditsPercent == 25) #expect(snapshot.creditsUsed == 12.50) #expect(snapshot.creditsTotal == 50) @@ -39,6 +40,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO PRO") + #expect(snapshot.displayPlanName == "Kiro Pro") #expect(snapshot.creditsPercent == 80) #expect(snapshot.creditsUsed == 40.00) #expect(snapshot.creditsTotal == 50) @@ -202,6 +204,90 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + @Test + func `parses kiro cli two usage format`() throws { + let output = """ + \u{001B}[1mEstimated Usage\u{001B}[0m | resets on 2026-06-01 | \u{001B}[mKIRO FREE\u{001B}[0m + + 🎁 Bonus credits: 45.53/2000 credits used, expires in 19 days + + \u{001B}[1mCredits\u{001B}[0m (0.17 of 50 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + + Overages: \u{001B}[1mDisabled\u{001B}[0m + + To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse( + output: output, + accountEmail: "person@example.com", + authMethod: "Google") + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + #expect(snapshot.creditsUsed == 0.17) + #expect(snapshot.creditsTotal == 50) + #expect(snapshot.creditsRemaining == 49.83) + #expect(snapshot.bonusCreditsUsed == 45.53) + #expect(snapshot.bonusCreditsTotal == 2000) + #expect(snapshot.bonusCreditsRemaining == 1954.47) + #expect(snapshot.bonusExpiryDays == 19) + #expect(snapshot.overagesStatus == "Disabled") + #expect(snapshot.manageURL == "https://app.kiro.dev/account/usage") + #expect(snapshot.resetsAt != nil) + } + + @Test + func `parses kiro overage credits and estimated cost`() throws { + let output = """ + Estimated Usage | resets on 2026-06-01 | KIRO PRO + Credits (1000.00 of 1000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + + Overages: Enabled billed at $0.04 per request + Credits used: 40.29 + Est. cost: $1.61 USD + + To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "KIRO PRO") + #expect(snapshot.creditsUsed == 1000) + #expect(snapshot.creditsTotal == 1000) + #expect(snapshot.overagesStatus == "Enabled billed at $0.04 per request") + #expect(snapshot.overageCreditsUsed == 40.29) + #expect(snapshot.estimatedOverageCostUSD == 1.61) + } + + @Test + func `parses context usage`() throws { + let output = """ + Context window: 1.3% used (estimated) + ██████████████████████████████████████████████████████████████████████████████ 1.3% + + █ Context files 0.5% (estimated) + █ Tools 0.8% (estimated) + █ Kiro responses 0.0% (estimated) + █ Your prompts 0.0% (estimated) + """ + + let probe = KiroStatusProbe() + let context = try #require(probe.parseContextUsage(output: output)) + + #expect(context.totalPercentUsed == 1.3) + #expect(context.contextFilesPercent == 0.5) + #expect(context.toolsPercent == 0.8) + #expect(context.kiroResponsesPercent == 0) + #expect(context.promptsPercent == 0) + } + // MARK: - Snapshot Conversion @Test @@ -225,8 +311,10 @@ struct KiroStatusProbeTests { #expect(usage.primary?.usedPercent == 25.0) #expect(usage.primary?.resetsAt == resetDate) #expect(usage.secondary?.usedPercent == 25.0) // 5/20 * 100 - #expect(usage.loginMethod(for: .kiro) == "KIRO PRO") - #expect(usage.accountOrganization(for: .kiro) == "KIRO PRO") + #expect(usage.loginMethod(for: .kiro) == nil) + #expect(usage.accountOrganization(for: .kiro) == nil) + #expect(usage.kiroUsage?.displayPlanName == "Kiro Pro") + #expect(usage.kiroUsage?.creditsRemaining == 75) } @Test @@ -364,9 +452,28 @@ struct KiroStatusProbeTests { func `whoami success does not throw`() throws { let probe = KiroStatusProbe() - try probe.validateWhoAmIOutput( + let account = try probe.validateWhoAmIOutput( + stdout: """ + Logged in with Google + Email: user@example.com + """, + stderr: "", + terminationStatus: 0) + + #expect(account.authMethod == "Google") + #expect(account.email == "user@example.com") + } + + @Test + func `whoami legacy bare email parses account`() throws { + let probe = KiroStatusProbe() + + let account = try probe.validateWhoAmIOutput( stdout: "user@example.com", stderr: "", terminationStatus: 0) + + #expect(account.authMethod == nil) + #expect(account.email == "user@example.com") } } diff --git a/Tests/CodexBarTests/LocalizationBundleTests.swift b/Tests/CodexBarTests/LocalizationBundleTests.swift new file mode 100644 index 000000000..1bf64988e --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationBundleTests { + @Test + func `packaged app resolves localization bundle from resources`() throws { + let fixture = try Self.makeAppBundleFixture(includeLocalizationBundle: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL.lastPathComponent == "CodexBar_CodexBar.bundle") + #expect(bundle.path(forResource: "en", ofType: "lproj") != nil) + } + + @Test + func `packaged app falls back to main bundle without touching SwiftPM module`() throws { + let fixture = try Self.makeAppBundleFixture(includeLocalizationBundle: false) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL == fixture.appBundle.bundleURL) + } + + @Test + func `packaged app resolves raw copied localization resources from main bundle`() throws { + let fixture = try Self.makeAppBundleFixture( + includeLocalizationBundle: false, + includeMainLocalization: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL == fixture.appBundle.bundleURL) + #expect(bundle.path(forResource: "en", ofType: "lproj") != nil) + } + + @Test + func `empty localized values fall back to English`() throws { + let fixture = try Self.makeAppBundleFixture( + includeLocalizationBundle: true, + includeEmptyChineseLocalization: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let resourceBundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + let zhPath = try #require(resourceBundle.path(forResource: "zh-Hans", ofType: "lproj")) + let zhBundle = try #require(Bundle(path: zhPath)) + + #expect(codexBarLocalizedString("Settings", bundle: zhBundle, resourceBundle: resourceBundle) == "Settings") + #expect(codexBarLocalizedString("Missing", bundle: zhBundle, resourceBundle: resourceBundle) == "Missing") + } + + @Test + func `managed Codex login failure includes CLI recovery guidance`() { + let message = L("managed_login_failed") + + #expect(message.contains("codex --version")) + #expect(message.contains("@openai/codex@latest")) + } + + private static func makeAppBundleFixture( + includeLocalizationBundle: Bool, + includeMainLocalization: Bool = false, + includeEmptyChineseLocalization: Bool = false) throws -> (root: URL, appBundle: Bundle) + { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "codexbar-localization-\(UUID().uuidString)", + isDirectory: true) + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let resourcesURL = contentsURL.appendingPathComponent("Resources", isDirectory: true) + try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let info = """ + + + + + CFBundleExecutableCodexBar + CFBundleIdentifiercom.steipete.codexbar.tests + CFBundleNameCodexBar + CFBundlePackageTypeAPPL + + + """ + try info.write( + to: contentsURL.appendingPathComponent("Info.plist"), + atomically: true, + encoding: .utf8) + + if includeMainLocalization { + try Self.writeEnglishLocalization(to: resourcesURL.appendingPathComponent("en.lproj", isDirectory: true)) + } + + if includeLocalizationBundle { + let bundleURL = resourcesURL.appendingPathComponent("CodexBar_CodexBar.bundle", isDirectory: true) + try Self.writeEnglishLocalization(to: bundleURL.appendingPathComponent("en.lproj", isDirectory: true)) + if includeEmptyChineseLocalization { + try Self.writeEmptyChineseLocalization( + to: bundleURL.appendingPathComponent("zh-Hans.lproj", isDirectory: true)) + } + } + + let appBundle = try #require(Bundle(url: appURL)) + return (root, appBundle) + } + + private static func writeEnglishLocalization(to lprojURL: URL) throws { + try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) + try "\"Settings\" = \"Settings\";\n".write( + to: lprojURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8) + } + + private static func writeEmptyChineseLocalization(to lprojURL: URL) throws { + try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) + try "\"Settings\" = \"\";\n".write( + to: lprojURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 48c0063af..58c27b2f6 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -113,6 +113,101 @@ struct ManagedCodexAccountServiceTests { #expect(FileManager.default.fileExists(atPath: storedTeam.managedHomePath)) } + @Test + func `same workspace provider id with different emails does not overwrite existing account`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingID = try #require(UUID(uuidString: "10101010-2020-3030-4040-505050505050")) + let existingAccount = ManagedCodexAccount( + id: existingID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "team-4107"), email: "mich.aelfmk5542@gmail.com", plan: "Team"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "team-4107": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "team-4107", + workspaceLabel: "4107"), + ])) + + let added = try await service.authenticateManagedAccount() + + let original = try #require( + store.snapshot.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")) + let newAccount = try #require( + store.snapshot.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")) + #expect(store.snapshot.accounts.count == 2) + #expect(original.id == existingID) + #expect(original.managedHomePath == existingHome.path) + #expect(newAccount.id == added.id) + #expect(newAccount.id != existingID) + #expect(newAccount.workspaceLabel == "4107") + #expect(FileManager.default.fileExists(atPath: existingHome.path)) + #expect(FileManager.default.fileExists(atPath: newAccount.managedHomePath)) + } + + @Test + func `selected workspace is persisted and used as account identity`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + let workspaces = [ + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-personal", + workspaceLabel: "Personal"), + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ] + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: WritingManagedCodexLoginRunner( + credentials: CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "workspace-personal", + lastRefresh: nil)), + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-personal"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver( + identities: Dictionary(uniqueKeysWithValues: workspaces.map { ($0.workspaceAccountID, $0) }), + availableIdentities: workspaces), + workspaceSelector: StubManagedCodexWorkspaceSelector(selectedWorkspaceID: "workspace-team")) + + let account = try await service.authenticateManagedAccount() + let credentials = try CodexOAuthCredentialsStore.load(env: ["CODEX_HOME": account.managedHomePath]) + + #expect(account.providerAccountID == "workspace-team") + #expect(account.workspaceLabel == "Team") + #expect(credentials.accountId == "workspace-team") + #expect(store.snapshot.accounts.count == 1) + } + @Test func `reauth keeps previous home when store write fails`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -623,7 +718,7 @@ struct ManagedCodexAccountServiceTests { } @Test - func `remove fails closed for home outside managed root`() async throws { + func `remove drops account but leaves unsafe home untouched`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let outsideRoot = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, @@ -651,11 +746,9 @@ struct ManagedCodexAccountServiceTests { identityReader: StubManagedCodexIdentityReader.emails([]), workspaceResolver: StubManagedCodexWorkspaceResolver()) - await #expect(throws: ManagedCodexAccountServiceError.unsafeManagedHome(account.managedHomePath)) { - try await service.removeManagedAccount(id: account.id) - } + try await service.removeManagedAccount(id: account.id) - #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.isEmpty) #expect(FileManager.default.fileExists(atPath: outsideRoot.path)) } } @@ -750,6 +843,19 @@ private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning { result: CodexLoginRunner.Result(outcome: .success, output: "ok")) } +private struct WritingManagedCodexLoginRunner: ManagedCodexLoginRunning { + let credentials: CodexOAuthCredentials + + func run(homePath: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + do { + try CodexOAuthCredentialsStore.save(self.credentials, env: ["CODEX_HOME": homePath]) + return CodexLoginRunner.Result(outcome: .success, output: "ok") + } catch { + return CodexLoginRunner.Result(outcome: .failed(status: 1), output: String(describing: error)) + } + } +} + private enum TestManagedCodexAccountStoreError: Error, Equatable { case writeFailed } @@ -787,9 +893,14 @@ private final class StubManagedCodexIdentityReader: ManagedCodexIdentityReading, private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { let identities: [String: CodexOpenAIWorkspaceIdentity] + let availableIdentities: [CodexOpenAIWorkspaceIdentity] - init(identities: [String: CodexOpenAIWorkspaceIdentity] = [:]) { + init( + identities: [String: CodexOpenAIWorkspaceIdentity] = [:], + availableIdentities: [CodexOpenAIWorkspaceIdentity] = []) + { self.identities = identities + self.availableIdentities = availableIdentities } func resolveWorkspaceIdentity( @@ -798,4 +909,20 @@ private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { self.identities[providerAccountID] } + + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + self.availableIdentities + } +} + +private struct StubManagedCodexWorkspaceSelector: ManagedCodexWorkspaceSelecting { + let selectedWorkspaceID: String? + + func selectWorkspace( + email _: String, + currentWorkspaceID _: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + workspaces.first { $0.workspaceAccountID == self.selectedWorkspaceID } + } } diff --git a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift index b07720ff0..274761cf5 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift @@ -204,6 +204,36 @@ func `FileManagedCodexAccountStore keeps same email rows when hydrated provider #expect(loaded.account(email: "user@example.com", providerAccountID: "account-beta")?.id == secondID) } +@Test +func `managed account set keeps same provider account I D when emails differ`() { + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-1", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let second = ManagedCodexAccount( + id: secondID, + email: "mich.aelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-2", + createdAt: 30, + updatedAt: 40, + lastAuthenticatedAt: nil) + + let set = ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [first, second]) + + #expect(set.accounts.count == 2) + #expect(set.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == firstID) + #expect(set.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == secondID) +} + @Test func `FileManagedCodexAccountStore hydrates provider account I D from id token when account field is absent`() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/CodexBarTests/ManusCookieHeaderTests.swift b/Tests/CodexBarTests/ManusCookieHeaderTests.swift new file mode 100644 index 000000000..6f760d3d9 --- /dev/null +++ b/Tests/CodexBarTests/ManusCookieHeaderTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ManusCookieHeaderTests { + @Test + func `bare token resolves directly`() { + #expect(ManusCookieHeader.token(from: "abc123") == "abc123") + } + + @Test + func `extracts session_id from cookie header`() { + let header = "foo=bar; session_id=token-a; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-a") + } + + @Test + func `extracts mixed case session id from cookie header`() { + let header = "foo=bar; Session_ID=token-b; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-b") + } + + @Test + func `unsupported cookie header returns nil`() { + #expect(ManusCookieHeader.token(from: "foo=bar; hello=world") == nil) + } + + #if os(macOS) + @Test + func `importer session info extracts session token`() throws { + let cookies = try [ + #require(self.makeCookie(name: "session_id", value: "cookie-token")), + ] + let session = ManusCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome") + #expect(session.sessionToken == "cookie-token") + } + + private func makeCookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: name, + .value: value, + .secure: "TRUE", + ]) + } + #endif +} diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift new file mode 100644 index 000000000..be69fdf51 --- /dev/null +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -0,0 +1,310 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ManusProviderTests { + private static let now = Date(timeIntervalSince1970: 1_744_000_000) + + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + 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( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func stubResponse() -> ManusCreditsResponse { + ManusCreditsResponse( + totalCredits: 120, + freeCredits: 20, + periodicCredits: 80, + addonCredits: 10, + refreshCredits: 30, + maxRefreshCredits: 300, + proMonthlyCredits: 100, + eventCredits: 10, + nextRefreshTime: Date(timeIntervalSince1970: 1_744_003_600), + refreshInterval: "daily") + } + + private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T { + let service = "manus-provider-tests-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await operation() + } + } + + @Test + func `off mode ignores environment session token`() async { + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `manual mode invalid cookie does not fall back to cache or environment`() async { + await self.withIsolatedCacheStore { + CookieHeaderCache.store( + provider: .manus, + cookieHeader: "session_id=cached-token", + sourceLabel: "web") + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .manual, + manualCookieHeader: "foo=bar")) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + do { + _ = try await strategy.fetch(context) + Issue.record("Expected invalid manual cookie instead of falling back to cache/environment") + } catch let error as ManusAPIError { + #expect(error == .invalidCookie) + } catch { + Issue.record("Expected ManusAPIError.invalidCookie, got \(error)") + } + } + } + + @Test + func `environment token does not populate browser cache`() async throws { + try await self.withIsolatedCacheStore { + #if os(macOS) + ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in + throw ManusCookieImportError.noCookies + } + ManusCookieImporter.importSessionOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionsOverrideForTesting = nil + ManusCookieImporter.importSessionOverrideForTesting = nil + } + #endif + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + #if os(macOS) + @Test + func `invalid browser token falls back to environment token`() async throws { + try await self.withIsolatedCacheStore { + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + + let attempts = LockedArray() + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + attempts.append(token) + if token == "browser-token" { + throw ManusAPIError.invalidToken + } + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attempts.snapshot() == ["browser-token", "env-token"]) + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + @Test + func `browser token populates cache after successful fetch`() async throws { + try await self.withIsolatedCacheStore { + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "browser-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + let cached = CookieHeaderCache.load(provider: .manus) + #expect(cached?.cookieHeader == "session_id=browser-token") + } + } + #endif + + @Test + func `settings reader accepts full cookie header from environment`() { + let env = ["MANUS_COOKIE": "foo=bar; session_id=env-cookie-token; baz=qux"] + #expect(ManusSettingsReader.sessionToken(environment: env) == "env-cookie-token") + } + + @Test + func `parse response tolerates sparse live payload`() throws { + let data = Data(""" + { + "totalCredits": 2869, + "freeCredits": 1500, + "periodicCredits": 1369, + "proMonthlyCredits": 4000, + "maxRefreshCredits": 300, + "nextRefreshTime": "2026-04-13T00:00:00Z", + "refreshInterval": "daily", + "userFlag": { "drc16": true } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 2869) + #expect(response.periodicCredits == 1369) + #expect(response.proMonthlyCredits == 4000) + #expect(response.refreshCredits == 0) + #expect(response.addonCredits == 0) + #expect(response.maxRefreshCredits == 300) + #expect(response.nextRefreshTime != nil) + + let snapshot = response.toUsageSnapshot(now: Self.now) + #expect(snapshot.providerCost == nil) + #expect(snapshot.primary?.usedPercent ?? 0 > 65) + #expect(snapshot.primary?.resetDescription == "Total 2,869 • Free 1,500") + #expect(snapshot.secondary?.usedPercent == 100) + #expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300") + } + + @Test + func `parse response rejects payload without credits fields`() { + let data = Data(#"{"error":"unauthorized","message":"session expired"}"#.utf8) + + #expect(throws: ManusAPIError.self) { + try ManusUsageFetcher.parseResponse(data) + } + } + + @Test + func `parse response accepts wrapped envelope`() throws { + let data = Data(""" + { + "data": { + "totalCredits": 100, + "proMonthlyCredits": 200, + "periodicCredits": 50, + "maxRefreshCredits": 10, + "refreshCredits": 5 + } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 100) + #expect(response.periodicCredits == 50) + } +} diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index df37232dc..267f3098b 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -41,4 +41,93 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 25) } + + @Test + func `automatic metric uses claude enterprise spend limit`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(abs((window?.usedPercent ?? 0) - 6.703) < 0.0001) + } + + @Test + func `automatic metric uses claude web spend limit placeholder`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(abs((window?.usedPercent ?? 0) - 6.703) < 0.0001) + } + + @Test + func `automatic metric keeps claude quota window when extra usage is optional`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 42) + } + + @Test + func `automatic metric keeps claude zero quota window when reset exists`() { + let reset = Date(timeIntervalSince1970: 1000) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: reset, resetDescription: "later"), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetsAt == reset) + } } diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift new file mode 100644 index 000000000..d924138ef --- /dev/null +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarVisibilityWatcherTests { + @Test + func `does not flag intentionally hidden status item`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without attached window`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without button`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: false, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item with zero width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `allows visible item attached to a screen with width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item attached to a detached screen`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `guidance shows once then repeats after a day`() throws { + let defaults = try #require(UserDefaults(suiteName: "MenuBarVisibilityWatcherTests")) + defaults.removePersistentDomain(forName: "MenuBarVisibilityWatcherTests") + let now = Date(timeIntervalSince1970: 1000) + + #expect(MenuBarVisibilityWatcher.shouldShowGuidance(defaults: defaults, now: now)) + + MenuBarVisibilityWatcher.markGuidanceShown(defaults: defaults, now: now) + + #expect(!MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval - 1))) + #expect(MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval))) + } + + @Test + func `startup recovery triggers for blocked visible snapshot`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [blocked])) + } + + @Test + func `startup recovery triggers when one split status item is blocked`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [healthy, blocked])) + } + + @Test + func `startup recovery ignores stale checks`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(MenuBarVisibilityWatcher.startupFreshnessInterval + 1), + snapshots: [blocked])) + } + + @Test + func `startup recovery ignores healthy visible snapshot`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [healthy])) + } + + @Test + func `screen change recovery triggers when a display is removed with visible status item`() { + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [healthy])) + } + + @Test + func `screen change recovery ignores display removal when no status item is visible`() { + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [hidden])) + } + + @Test + func `screen change recovery triggers for blocked status item without display count change`() { + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + previousScreenCount: 1, + currentScreenCount: 1, + snapshots: [blocked])) + } + + @Test + func `screen change recovery ignores healthy item when display count does not shrink`() { + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + previousScreenCount: 1, + currentScreenCount: 2, + snapshots: [healthy])) + } +} diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift new file mode 100644 index 000000000..27c3fdedf --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -0,0 +1,52 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardCostHintTests { + @Test + func `claude cost hint explains cache tokens and status line drift`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 1.23, + last30DaysTokens: 456, + last30DaysCostUSD: 78.9, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-14", + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 300, + cacheCreationTokens: 400, + totalTokens: 703, + costUSD: 1.23, + 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?.hintLine?.contains("cache read/write tokens") == true) + #expect(model.tokenUsage?.hintLine?.contains("Claude Code /status") == true) + } +} diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift new file mode 100644 index 000000000..8c63c506f --- /dev/null +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardDeepSeekTests { + @Test + func `model shows balance as status text instead of percentage detail`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .deepseek, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + + 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)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Balance") + #expect(primary.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index f7760a665..c941370ec 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -5,6 +5,153 @@ import Testing @testable import CodexBar struct MenuCardModelCodexProjectionTests { + @Test + func `codex plan only snapshot shows limits unavailable placeholder`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + 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: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.metrics.isEmpty) + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex plan only snapshot keeps actionable refresh errors visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + 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: "Codex connection failed: timed out.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex connection failed: timed out.") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex account fallback shows limits unavailable instead of no limits error`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.email == "user@example.com") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex no account fallback keeps no limits error visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == UsageError.noRateLimitsFound.errorDescription) + } + @Test func `builds metrics using used percent when enabled`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index a0ee41663..d8e451cd0 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -4,6 +4,711 @@ import SwiftUI import Testing @testable import CodexBar +struct OverviewMenuCardVisibilityTests { + @Test + func `overview hides cards that only contain an error`() throws { + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "No Cursor session found.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.isOverviewErrorOnly) + } + + @Test + func `overview keeps cards with graceful unavailable placeholders`() throws { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.placeholder == "Limits not available") + #expect(!model.isOverviewErrorOnly) + } +} + +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 { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 1.25, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [ + ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), + ], + models: [ + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: "claude-sonnet-4-20250514", + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + 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)) + + #expect(model.metrics.isEmpty) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$1.25") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") + #expect(model.inlineUsageDashboard?.detailLines + .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) + #expect(model.planText == "Admin API") + } + + @Test + func `openrouter period usage gets inline dashboard`() throws { + 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)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$60.00") + #expect(model.inlineUsageDashboard?.points.map(\.label) == ["Today", "Week", "Month"]) + #expect(model.inlineUsageDashboard?.detailLines.contains("Rate limit: 100 / 10s") == true) + } + + @Test + func `local cost history gets inline dashboard`() 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: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-sonnet-4", + costUSD: 0.12, + totalTokens: 150), + ]), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["claude-opus-4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-opus-4", + costUSD: 0.25, + totalTokens: 275), + ]), + ] + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + daily: daily, + updatedAt: now) + + let model = 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)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.25") + #expect(model.inlineUsageDashboard?.points.count == 2) + #expect(model.inlineUsageDashboard?.detailLines.contains { $0.contains("claude-opus-4") } == true) + } + + @Test + func `mistral daily buckets get inline dashboard`() 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: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 0, + 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: 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?.kpis.first?.value == "€1.5000") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.5000") + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: mistral-large") == true) + } + + @Test + func `zai hourly usage gets inline dashboard`() throws { + let now = try #require(Self.zaiDate("2023-11-15 12:00")) + let metadata = try #require(ProviderDefaults.metadata[.zai]) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: ZaiModelUsageData( + xTime: ["2023-11-14 12:00", "2023-11-15 12:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100, 200]), + ]), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + 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)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "300") + #expect(model.inlineUsageDashboard?.points.map(\.label) == ["12", "12"]) + #expect(Set(model.inlineUsageDashboard?.points.map(\.id) ?? []).count == 2) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: glm-4.5") == true) + } + + private static func zaiDate(_ text: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.date(from: text) + } +} + +struct FactoryMenuCardModelTests { + @Test + func `factory token rate billing uses time window labels`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .factory, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) + } + + @Test + func `factory legacy billing keeps pool labels`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .factory, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Standard", "Premium"]) + } + + @Test + func `factory extra usage balance renders as optional balance`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 25, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let visible = UsageMenuCardView.Model.make(.init( + provider: .factory, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + #expect(visible.providerCost?.title == "Extra usage") + #expect(visible.providerCost?.spendLine == "Balance: $25.00") + #expect(visible.providerCost?.percentUsed == nil) + #expect(visible.providerCost?.percentLine == nil) + + let hidden = UsageMenuCardView.Model.make(.init( + provider: .factory, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + #expect(hidden.providerCost == nil) + } +} + +struct MiniMaxMenuCardModelTests { + @Test + func `minimax service metrics use quota card copy`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let used = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(used.metrics.first?.title == "Text Generation") + #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") + #expect(used.metrics.first?.detailRightText == "Used 20%") + #expect(used.metrics.first?.detailText == "10:00-15:00(UTC+8)") + #expect(used.metrics.first?.percent == 20) + #expect(used.metrics.first?.cardStyle == true) + } + + @Test + func `text generation badge uses real window type when multiple windows exist`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Today", + timeRange: "2026/05/16 00:00 - 2026/05/17 00:00", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "05/11 00:00 - 05/18 00:00(UTC+8)", + usage: 20, + limit: 100, + percent: 20, + resetsAt: now.addingTimeInterval(7200), + resetDescription: "Resets in 2 hours"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[0].title == "Text Generation · Today") + #expect(model.metrics[1].title == "Text Generation · Weekly") + } +} + +struct ClaudeMenuCardCostTests { + @Test + func `claude extra usage labels monthly denominator as cap`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now), + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + 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.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") + } +} + struct MenuCardModelTests { @Test func `builds metrics using remaining percent`() throws { @@ -69,11 +774,14 @@ struct MenuCardModelTests { tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [25, 0]], now: now)) #expect(model.providerName == "Codex") #expect(model.metrics.count == 2) #expect(model.metrics.first?.percent == 78) + #expect(model.metrics.first?.warningMarkerPercents == [50, 20]) + #expect(model.metrics[1].warningMarkerPercents == [25]) #expect(model.planText == "Plus") #expect(model.subtitleText.hasPrefix("Updated")) #expect(model.progressColor != Color.clear) @@ -258,6 +966,7 @@ struct MenuCardModelTests { #expect(model.tokenUsage?.monthLine.contains("456") == true) #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + #expect(model.tokenUsage?.hintLine == "Estimated from local Codex logs for the selected account.") } @Test diff --git a/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift new file mode 100644 index 000000000..a6faa8694 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardOptionalUsageModelTests { + @Test + func `hides codex credits when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let credits = CreditsSnapshot(remaining: 12, events: [], updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: credits, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: credits, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `claude model shows peak hours note when enabled`() throws { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = try #require(TimeZone(identifier: "America/New_York")) + let now = try #require(cal.date(from: DateComponents(year: 2026, month: 3, day: 25, hour: 10))) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + 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, + claudePeakHoursEnabled: true, + now: now)) + + #expect(model.usageNotes.count == 1) + #expect(model.usageNotes.first?.contains("Peak") == true) + } + + @Test + func `claude model hides peak hours note when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + 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, + claudePeakHoursEnabled: false, + now: now)) + + #expect(model.usageNotes.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift new file mode 100644 index 000000000..da79bb81d --- /dev/null +++ b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift @@ -0,0 +1,82 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardProviderRegressionTests { + @Test + func `open router model shows daily and weekly key spend`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: 20, + keyUsage: 0.5, + keyUsageDaily: 0.12, + keyUsageWeekly: 0.74, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + 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.usageNotes == ["Today: $0.12 · This week: $0.74"]) + } + + @Test + func `copilot over quota usage keeps used percentage detail`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 115, windowMinutes: nil, resetsAt: nil, resetDescription: "115% used"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + 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)) + + let metric = try #require(model.metrics.first) + #expect(metric.percent == 0) + #expect(metric.percentLabel == "0% left") + #expect(metric.detailLeftText == "115% used") + } +} diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift new file mode 100644 index 000000000..f3bac6a62 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardQuotaWarningMarkerTests { + @Test + func `omits quota warning markers for disabled windows`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Plus Plan") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now, + identity: identity) + let codexProjection = 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: codexProjection, + 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, + quotaWarningThresholds: [.session: [50], .weekly: []], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics.first?.warningMarkerPercents == [50]) + #expect(model.metrics[1].warningMarkerPercents.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardSubtitleTests.swift b/Tests/CodexBarTests/MenuCardSubtitleTests.swift new file mode 100644 index 000000000..8407c1c10 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardSubtitleTests.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardSubtitleTests { + @Test + func `subtitle uses injected current time`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == UsageFormatter.updatedString(from: updatedAt, now: now)) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift new file mode 100644 index 000000000..d45b96429 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift @@ -0,0 +1,92 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorOpenAIAPITests { + @Test + func `openai api admin usage appears in descriptor summaries`() throws { + let suite = "MenuDescriptorOpenAIAPITests-admin-summary" + 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 now = Date(timeIntervalSince1970: 1_700_179_200) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 5, + requests: 8, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 8, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150), + ]), + 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: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let descriptor = MenuDescriptor.build( + provider: .openai, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Today: $12.50 · 1.5K tokens")) + #expect(lines.contains("7d: $17.50 · 48 requests")) + #expect(lines.contains("30d: $17.50 · 48 requests")) + #expect(lines.contains("Top model: gpt-5.2-codex")) + #expect(!lines.contains("No usage yet")) + } +} diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift new file mode 100644 index 000000000..6dea64ee3 --- /dev/null +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -0,0 +1,714 @@ +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif + +@Suite(.serialized) +struct MiMoProviderTests { + 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 `cookie header normalizer keeps required mimo cookies`() { + let raw = """ + curl 'https://platform.xiaomimimo.com/api/v1/balance' \ + -H 'Cookie: userId=123; api-platform_serviceToken=svc-token; ignored=value; api-platform_ph=ph-token' + """ + + let normalized = MiMoCookieHeader.normalizedHeader(from: raw) + + #expect(normalized == "api-platform_ph=ph-token; api-platform_serviceToken=svc-token; userId=123") + } + + @Test + func `cookie header normalizer rejects missing auth cookies`() { + let normalized = MiMoCookieHeader.normalizedHeader(from: "Cookie: userId=123") + + #expect(normalized == nil) + } + + @Test + func `cookie header builder keeps mimo auth cookies from one scope`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "platform-user", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_ph", + value: "platform-ph", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_ph=platform-ph; api-platform_serviceToken=platform-token; userId=platform-user") + } + + @Test + func `cookie header builder prefers more specific matching cookie`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "api-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: ".xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "irrelevant", + value: "ignored", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=platform-token; userId=api-user") + } + + @Test + func `cookie header builder rejects partial path prefix matches`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "partial-path-user", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "valid-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "partial-path-token", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "valid-token", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=valid-token; userId=valid-user") + } + + @Test + func `cookie header builder accepts slash terminated path prefixes`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "slash-user", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "slash-token", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=slash-token; userId=slash-user") + } + + @Test + func `usage snapshot exposes balance through identity plan text`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .mimo) == "Balance: $25.51") + } + + @Test + func `usage snapshot shows token plan as primary when available`() { + let resetDate = Date(timeIntervalSince1970: 1_778_025_599) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + planPeriodEnd: resetDate, + planExpired: false, + tokenUsed: 10_100_158, + tokenLimit: 200_000_000, + tokenPercent: 0.0505, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? .nan) - 5.05) < 0.0001) + #expect(usage.primary?.resetDescription == "10,100,158 / 200,000,000 Credits") + #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.loginMethod(for: .mimo) == "Standard") + } + + @Test + func `usage snapshot falls back to balance when no token plan`() { + let snapshot = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + planCode: nil, + planPeriodEnd: nil, + planExpired: false, + tokenUsed: 0, + tokenLimit: 0, + tokenPercent: 0, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .mimo) == "Balance: $0.00") + } + + @Test + func `parses balance payload`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "frozenBalance": null, + "currency": "USD", + "overdraftLimit": null + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(snapshot.updatedAt == now) + } + + @Test + func `parses token plan detail payload`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "planCode": "standard", + "currentPeriodEnd": "2026-05-04 23:59:59", + "expired": false + } + } + """ + + let detail = try MiMoUsageFetcher.parseTokenPlanDetail(from: Data(json.utf8)) + + #expect(detail.planCode == "standard") + #expect(detail.expired == false) + #expect(detail.periodEnd != nil) + } + + @Test + func `parses token plan usage payload`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "monthUsage": { + "percent": 0.0505, + "items": [ + { + "name": "month_total_token", + "used": 10100158, + "limit": 200000000, + "percent": 0.0505 + } + ] + } + } + } + """ + + let usage = try MiMoUsageFetcher.parseTokenPlanUsage(from: Data(json.utf8)) + + #expect(usage.used == 10_100_158) + #expect(usage.limit == 200_000_000) + #expect(usage.percent == 0.0505) + } + + @Test + func `combined snapshot merges balance and token plan`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let balanceJSON = """ + {"code":0,"message":"","data":{"balance":"25.51","currency":"USD"}} + """ + let detailJSON = """ + {"code":0,"message":"","data":{"planCode":"standard","currentPeriodEnd":"2026-05-04 23:59:59","expired":false}} + """ + let usageJSON = """ + { + "code": 0, + "message": "", + "data": { + "monthUsage": { + "percent": 0.0505, + "items": [ + { + "name": "month_total_token", + "used": 10100158, + "limit": 200000000, + "percent": 0.0505 + } + ] + } + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseCombinedSnapshot( + balanceData: Data(balanceJSON.utf8), + tokenDetailData: Data(detailJSON.utf8), + tokenUsageData: Data(usageJSON.utf8), + now: now) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(snapshot.planCode == "standard") + #expect(snapshot.tokenUsed == 10_100_158) + #expect(snapshot.tokenLimit == 200_000_000) + #expect(snapshot.tokenPercent == 0.0505) + } + + @Test + func `fetch usage hits mimo balance endpoint with browser headers`() async throws { + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + } + + let lock = NSLock() + var requestedPaths: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + lock.withLock { + requestedPaths.append(url.path) + } + #expect(request.value(forHTTPHeaderField: "Cookie") == "api-platform_serviceToken=svc-token; userId=123") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.value(forHTTPHeaderField: "x-timeZone") == "UTC+01:00") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://platform.xiaomimimo.com/#/console/balance") + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "Cookie: userId=123; api-platform_serviceToken=svc-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + now: Date(timeIntervalSince1970: 1_742_771_200)) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(requestedPaths.contains("/api/v1/balance")) + } + + @Test + @MainActor + func `provider detail plan row formats mimo as balance`() { + let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + + #expect(row?.label == "Balance") + #expect(row?.value == "$25.51") + } + + @Test(arguments: [UsageProvider.openrouter, .mimo]) + @MainActor + func `menu descriptor renders balance providers without duplicate prefix`(provider: UsageProvider) throws { + let suite = "MiMoProviderTests-menu-balance-\(provider.rawValue)" + 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) + store._setSnapshotForTesting(self.makeBalanceSnapshot(provider: provider), provider: provider) + + let descriptor = MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Balance: $25.51")) + #expect(!lines.contains("Balance: Balance: $25.51")) + } + + @Test + func `mimo web strategy unavailable when cookie source is off`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .off, + manualCookieHeader: nil))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + @Test + func `mimo manual mode does not report available from cached browser session`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + @Test + func `mimo manual mode rejects invalid header instead of falling back to cached session`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + await #expect(throws: MiMoSettingsError.invalidCookie) { + _ = try await strategy.fetch(context) + } + } + + @Test + func `mimo web strategy retries imported sessions after decode failure`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + MiMoCookieImporter.importSessionsOverrideForTesting = nil + CookieHeaderCache.clear(provider: .mimo) + } + + CookieHeaderCache.clear(provider: .mimo) + CookieHeaderCache.store(provider: .mimo, cookieHeader: "invalid", sourceLabel: "invalid") + + MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in + [ + .init( + cookieHeader: "api-platform_serviceToken=expired-token; userId=111", + sourceLabel: "Expired Chrome"), + .init( + cookieHeader: "api-platform_serviceToken=valid-token; userId=222", + sourceLabel: "Active Chrome"), + ] + } + + let lock = NSLock() + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + lock.withLock { + requestedCookies.append(cookie) + } + + if cookie.contains("expired-token") { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"])! + return (response, Data("login".utf8)) + } + + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.count == 6) + #expect(requestedCookies.contains(where: { $0.contains("expired-token") })) + #expect(requestedCookies.contains(where: { $0.contains("valid-token") })) + #expect(result.usage.loginMethod(for: .mimo) == "Balance: $25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") + } + + #if os(macOS) + @Test + func `mimo importer merges profile stores before validating auth cookies`() { + let profile = BrowserProfile(id: "Default", name: "Default") + let primaryStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .primary, + label: "Chrome Default", + databaseURL: nil) + let networkStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .network, + label: "Chrome Default (Network)", + databaseURL: nil) + let expires = Date(timeIntervalSince1970: 1_900_000_000) + + let sessions = MiMoCookieImporter.sessionInfos(from: [ + BrowserCookieStoreRecords(store: primaryStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "userId", + path: "/", + value: "123", + expires: expires, + isSecure: true, + isHTTPOnly: false), + ]), + BrowserCookieStoreRecords(store: networkStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "token", + expires: expires, + isSecure: true, + isHTTPOnly: true), + ]), + ]) + + #expect(sessions.count == 1) + #expect(sessions.first?.sourceLabel == "Chrome Default") + #expect(sessions.first?.cookieHeader == "api-platform_serviceToken=token; userId=123") + } + #endif + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private func makeBalanceSnapshot(provider: UsageProvider) -> UsageSnapshot { + let updatedAt = Date(timeIntervalSince1970: 1_742_771_200) + switch provider { + case .openrouter: + return OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 24.49, + balance: 25.51, + usedPercent: 49, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: updatedAt).toUsageSnapshot() + case .mimo: + return MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: updatedAt).toUsageSnapshot() + default: + Issue.record("Unexpected provider \(provider.rawValue)") + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: updatedAt) + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot? = nil, + environment: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + private func makeCookie( + name: String, + value: String, + domain: String, + path: String = "/", + expiresAt: Date) throws -> HTTPCookie + { + let properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .expires: expiresAt, + .secure: "TRUE", + ] + return try #require(HTTPCookie(properties: properties)) + } +} + +final class MiMoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "mimo.test" + } + + 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/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift new file mode 100644 index 000000000..461c7f66a --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -0,0 +1,103 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MiniMaxMenuCardBillingTests { + @Test + func `minimax billing history renders inline dashboard`() throws { + let now = Date() + let billing = MiniMaxBillingSummary( + todayTokens: 1234, + last30DaysTokens: 5678, + todayCash: 1.5, + last30DaysCash: 4.25, + daily: [ + MiniMaxBillingDay(day: "2026-05-16", tokens: 1111, cash: 2.75), + MiniMaxBillingDay(day: "2026-05-17", tokens: 1234, cash: 1.5), + ], + topMethods: [MiniMaxBillingBreakdown(name: "chat", tokens: 2345, cash: 4.25)], + topModels: [MiniMaxBillingBreakdown(name: "MiniMax-M1", tokens: 2345, cash: 4.25)], + updatedAt: now) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Today", + timeRange: "2026/05/17 00:00 - 2026/05/18 00:00", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + ], + billingSummary: billing) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.accessibilityLabel == "MiniMax 30 day token usage trend") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "1.2K") + #expect(model.inlineUsageDashboard?.points.count == 2) + #expect(model.usageNotes.contains("Last 30 days: 5.7K tokens")) + + let hiddenModel = UsageMenuCardView.Model.make(.init( + provider: .minimax, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(hiddenModel.inlineUsageDashboard == nil) + #expect(!hiddenModel.usageNotes.contains("Last 30 days: 5.7K tokens")) + } +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 7cf0524bc..d713704ae 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -2,6 +2,75 @@ import Foundation import Testing @testable import CodexBarCore +struct MiniMaxAPISettingsReaderTests { + @Test + func `api token prefers coding plan specific environment key`() { + let token = MiniMaxAPISettingsReader.apiToken(environment: [ + "MINIMAX_API_KEY": "sk-api-standard", + "MINIMAX_CODING_API_KEY": "sk-cp-coding-plan", + ]) + + #expect(token == "sk-cp-coding-plan") + #expect(MiniMaxAPISettingsReader.apiKeyKind(token: token) == .codingPlan) + } + + @Test + func `api token falls back to generic environment key`() { + let token = MiniMaxAPISettingsReader.apiToken(environment: [ + "MINIMAX_API_KEY": "\"sk-api-standard\"", + ]) + + #expect(token == "sk-api-standard") + #expect(MiniMaxAPISettingsReader.apiKeyKind(token: token) == .standard) + } +} + +struct MiniMaxProviderStrategyTests { + 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 `browser cookie import is user initiated app only`() { + let appContext = self.makeContext(runtime: .app) + let cliContext = self.makeContext(runtime: .cli) + + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: appContext) == false) + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: cliContext) == false) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: appContext)) + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: cliContext) == false) + } + } + + private func makeContext(runtime: ProviderRuntime) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: runtime, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} + struct MiniMaxCookieHeaderTests { @Test func `normalizes raw cookie header`() { @@ -58,6 +127,49 @@ struct MiniMaxCookieHeaderTests { } struct MiniMaxUsageParserTests { + @Test + func `signed out check ignores login copy inside scripts`() { + let html = """ + + + + +
Coding Plan
+ + """ + + #expect(!MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + + @Test + func `signed out check still detects visible login copy`() { + let html = """ + + +
Log in
+ + """ + + #expect(MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + @Test func `parses coding plan snapshot`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -115,6 +227,158 @@ struct MiniMaxUsageParserTests { #expect(snapshot.resetsAt == expectedReset) } + @Test + func `parses model remains services using used quota semantics`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "model_name": "M2.7-highspeed", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first) + + #expect(service.displayName == "Text Generation") + #expect(service.usage == 750) + #expect(service.remaining == 250) + #expect(service.limit == 1000) + #expect(service.percent == 75) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 75) + } + + @Test + func `text generation includes weekly window when provided`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let weekStart = start - 2 * 24 * 60 * 60 * 1000 + let weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 6000, + "current_weekly_usage_count": 5376, + "weekly_start_time": \(weekStart), + "weekly_end_time": \(weekEnd) + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 2) + #expect(services[0].serviceType == "Text Generation") + #expect(services[0].windowType == "5 hours") + #expect(services[1].serviceType == "Text Generation") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 624) + #expect(services[1].limit == 6000) + #expect(services[1].timeRange.contains("/")) + #expect(services[1].timeRange.contains("UTC+8")) + #expect(!services[1].timeRange.hasPrefix("10:00-10:00")) + } + + @Test + func `legacy plan hides weekly when weekly total is missing or zero`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 1) + #expect(services[0].windowType == "5 hours") + } + + @Test + func `parses multi service payload and utc offset reset`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 8 * 3600)) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 11, + minute: 0))) + let expectedReset = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 15, + minute: 0))) + let json = """ + { + "data": { + "services": [ + { + "service_type": "Text Generation", + "window_type": "5 hours", + "time_range": "10:00-15:00(UTC+8)", + "usage": 2, + "limit": 10 + }, + { + "service_type": "Image", + "window_type": "Today", + "time_range": "2026/03/25 00:00 - 2026/03/26 00:00", + "usage": "5", + "limit": "50", + "percent": "10" + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].usage == 2) + #expect(services[0].remaining == 8) + #expect(services[0].percent == 20) + #expect(services[0].resetsAt == expectedReset) + #expect(services[1].usage == 5) + #expect(services[1].remaining == 45) + #expect(services[1].percent == 10) + } + @Test func `parses coding plan remains from data wrapper`() throws { let now = Date(timeIntervalSince1970: 1_700_000_100) @@ -263,6 +527,313 @@ struct MiniMaxUsageParserTests { try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8)) } } + + @Test + func `billing history aggregates records locally`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "consume_token_sum": 999999, + "total_cnt": 4, + "charge_records": [ + { + "consume_token": 1000, + "consume_cash_after_voucher": 1.25, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + }, + { + "consume_token": "2000", + "consume_cash": "2.50", + "ymd": "2026-05-16", + "method": "chat", + "model": "MiniMax-M2" + }, + { + "consume_input_token": 1200, + "consume_output_token": 1800, + "ymd": "2026-04-18", + "method": "audio", + "model": "speech-2.8" + }, + { + "consume_token": 4000, + "ymd": "2026-04-17", + "method": "old", + "model": "ignored" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1000) + #expect(summary.last30DaysTokens == 6000) + #expect(summary.todayCash == 1.25) + #expect(summary.last30DaysCash == 3.75) + #expect(summary.daily.map(\.day) == ["2026-04-18", "2026-05-16", "2026-05-17"]) + #expect(summary.topMethods.first?.name == "audio") + #expect(summary.topModels.first?.name == "speech-2.8") + } + + @Test + func `billing history preserves date only days in local calendar`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: -7 * 60 * 60)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1234) + #expect(summary.daily.map(\.day) == ["2026-05-17"]) + } + + @Test + func `web usage fetch attaches billing history when available`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(url.path == "/account/amount") + #expect(url.query?.contains("aggregate=false") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "HERTZ-SESSION=abc") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary?.todayTokens == 1234) + #expect(snapshot.billingSummary?.last30DaysTokens == 1234) + } + + @Test + func `web usage fetch keeps paginating billing history until 30 day cutoff`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + let page = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "page" }? + .value ?? "1" + let recordDay = page == "3" ? "2026-04-17" : "2026-05-17" + let records = (0..<100) + .map { _ in + """ + {"consume_token":1,"ymd":"\(recordDay)","method":"chat","model":"MiniMax-M1"} + """ + } + .joined(separator: ",") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 250, + "charge_records": [\(records)] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + let billingRequests = await transport.requests().filter { $0.url?.path == "/account/amount" } + #expect(billingRequests.count == 3) + #expect(snapshot.billingSummary?.last30DaysTokens == 200) + } + + @Test + func `web usage fetch skips billing history when optional usage is disabled`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + let requests = await transport.requests() + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + #expect(requests.count == 1) + } + + @Test + func `web usage fetch keeps quota when billing history is forbidden`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + } + + @Test + func `web usage fetch preserves stale bearer failure during billing history`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer stale") + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + authorizationToken: "stale", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + @Test + func `web usage fetch preserves billing history cancellation`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + private static let codingPlanJSON = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 10, + "current_interval_usage_count": 8, + "start_time": 1779019200, + "end_time": 1779037200, + "remains_time": 3600 + } + ] + } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } } struct MiniMaxAPIRegionTests { @@ -292,6 +863,16 @@ struct MiniMaxAPIRegionTests { #expect(remains.host == "api.minimaxi.com") } + @Test + func `billing history url uses account amount endpoint`() { + let url = MiniMaxUsageFetcher.resolveBillingHistoryURL(region: .chinaMainland, environment: [:], page: 2) + #expect(url.host == "platform.minimaxi.com") + #expect(url.path == "/account/amount") + #expect(url.query?.contains("page=2") == true) + #expect(url.query?.contains("limit=100") == true) + #expect(url.query?.contains("aggregate=false") == true) + } + @Test func `remains url override beats host`() { let env = [MiniMaxSettingsReader.remainsURLKey: "https://platform.minimaxi.com/custom/remains"] diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index ec1d6dd84..62df8aa23 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -28,6 +28,9 @@ struct MistralUsageParserTests { #expect(snapshot.modelCount == 2) #expect(snapshot.currency == "EUR") #expect(snapshot.currencySymbol == "€") + #expect(snapshot.daily.map(\.day) == ["2025-11-14", "2025-11-24"]) + #expect(snapshot.daily.first?.totalTokens == 11121 + 1115 + 20 + 500) + #expect(snapshot.daily.first?.models.first?.name == "mistral-large-latest") } @Test @@ -56,6 +59,49 @@ struct MistralUsageParserTests { #expect(snapshot.currency == "EUR") } + @Test + func `daily spend keeps non token Mistral units out of token totals`() throws { + let json = """ + { + "libraries_api": { + "pages": { + "models": { + "mistral-ocr-latest": { + "input": [ + { + "billing_metric": "pages", + "billing_display_name": "OCR pages", + "billing_group": "input", + "timestamp": "2025-11-15", + "value": 42, + "value_paid": 42 + } + ] + } + } + } + }, + "currency": "EUR", + "currency_symbol": "€", + "prices": [ + { + "billing_metric": "pages", + "billing_group": "input", + "price": "0.01" + } + ] + } + """ + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(abs(snapshot.totalCost - 0.42) < 0.0001) + #expect(snapshot.totalInputTokens == 0) + #expect(abs((snapshot.daily.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.totalTokens == 0) + #expect(abs((snapshot.daily.first?.models.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.models.first?.totalTokens == 0) + } + @Test func `parses dates from response`() throws { let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) @@ -82,7 +128,7 @@ struct MistralUsageParserTests { struct MistralUsageSnapshotConversionTests { @Test - func `converts cost into primary resetDescription so it surfaces as detail text`() { + func `converts cost into text only current month api spend`() { let snapshot = MistralUsageSnapshot( totalCost: 1.2345, currency: "EUR", @@ -96,17 +142,14 @@ struct MistralUsageSnapshotConversionTests { updatedAt: Date()) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary != nil) - #expect(usage.primary?.usedPercent == 0) - #expect(usage.primary?.resetDescription?.contains("€1.2345") == true) - // providerCost is intentionally nil: the menu card's providerCostSection requires - // limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost - // is surfaced via primary.resetDescription (rendered as detail text in the card). + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .mistral) + #expect(usage.identity?.loginMethod == "API spend: €1.2345 this month") #expect(usage.providerCost == nil) } @Test - func `converts zero cost with no-usage description`() { + func `converts zero cost into zero spend text`() { let snapshot = MistralUsageSnapshot( totalCost: 0, currency: "USD", @@ -120,7 +163,8 @@ struct MistralUsageSnapshotConversionTests { updatedAt: Date()) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary?.resetDescription == "No usage this month") + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "API spend: $0.0000 this month") } } diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift new file mode 100644 index 000000000..3a4d1807d --- /dev/null +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -0,0 +1,592 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ModelsDevPricingTests { + @Test + func `parses models dev subset`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.providers["openai"]?.name == "OpenAI") + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.cost?.cacheWrite == 3.75) + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.limit?.context == 1_000_000) + } + + @Test + func `looks up pricing by provider and model`() throws { + let catalog = try Self.fixtureCatalog() + + let openAI = try #require(catalog.pricing(providerID: "openai", modelID: "shared-model")) + let anthropic = try #require(catalog.pricing(providerID: "anthropic", modelID: "shared-model")) + + #expect(openAI.pricing.inputCostPerToken == 1 / 1_000_000.0) + #expect(openAI.pricing.outputCostPerToken == 2 / 1_000_000.0) + #expect(anthropic.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(anthropic.pricing.outputCostPerToken == 4 / 1_000_000.0) + } + + @Test + func `does not fall back across providers`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.pricing(providerID: "openai", modelID: "claude-sonnet-4-6") == nil) + #expect(catalog.pricing(providerID: "anthropic", modelID: "gpt-4o-mini") == nil) + } + + @Test + func `supports provider scoped alias normalization`() throws { + let catalog = try Self.fixtureCatalog() + + let anthropic = try #require(catalog.pricing( + providerID: "anthropic", + modelID: "anthropic.us-east-1.claude-sonnet-4-6-v1:0")) + let vertex = try #require(catalog.pricing( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6")) + + #expect(anthropic.normalizedModelID == "claude-sonnet-4-6") + #expect(vertex.normalizedModelID == "claude-sonnet-4-6@default") + #expect(vertex.pricing.inputCostPerToken == 3.1 / 1_000_000.0) + } + + @Test + func `converts models dev per million token prices to per token prices`() throws { + let pricing = try #require(try Self.fixtureCatalog().pricing( + providerID: "anthropic", + modelID: "claude-sonnet-4-6")? + .pricing) + + #expect(pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(pricing.outputCostPerToken == 15 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerToken == 0.3 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerToken == 3.75 / 1_000_000.0) + #expect(pricing.thresholdTokens == 200_000) + #expect(pricing.inputCostPerTokenAboveThreshold == 6 / 1_000_000.0) + #expect(pricing.outputCostPerTokenAboveThreshold == 22.5 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerTokenAboveThreshold == 0.6 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerTokenAboveThreshold == 7.5 / 1_000_000.0) + } + + @Test + func `stale cache is still readable`() throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let load = ModelsDevCache.load( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root) + + #expect(load.artifact != nil) + #expect(load.isStale) + #expect(load.error == nil) + } + + @Test + func `pipeline lookup reads cached pricing`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `network failure preserves last valid cache`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport(result: .failure(MockError.failed)))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog drops cached provider`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { "id": 7, "models": [] }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog drops cached model`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6@default": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog renames model key but keeps id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let renamedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini-renamed": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6-renamed": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((renamedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.normalizedModelID == "gpt-4o-mini") + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched matching model is not priceable`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6@default": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog canonicalizes alias model id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let canonicalizedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((canonicalizedCatalog, Self.response(status: 200)))))) + + let defaultLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6@default", + cacheRoot: root)) + let baseLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6", + cacheRoot: root)) + + #expect(defaultLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(baseLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog only has different pinned snapshot`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4@20250101": { + "id": "claude-sonnet-4@20250101", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4@20250201": { + "id": "claude-sonnet-4@20250201", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4@20250101", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + } + + @Test + func `refresh ignores unpriceable models in old cache continuity check`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 0.15, "output": 0.6 } + }, + "unpriced-preview": { + "id": "unpriced-preview" + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `fresh cache does not refresh`() async throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(), + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(transport.calls == 0) + } + + @Test + func `corrupt cache is ignored safely`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("not json".utf8).write(to: url) + + let load = ModelsDevCache.load(cacheRoot: root) + + #expect(load.artifact == nil) + #expect(load.isStale) + #expect(load.error == .invalidJSON) + } + + @Test + func `client fetches with mock transport`() async throws { + let data = try Self.fixtureData() + let client = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 200))))) + + let catalog = try await client.fetchCatalog() + + #expect(catalog.providers["google-vertex-anthropic"]?.models["claude-sonnet-4-6@default"]?.cost?.input == 3.1) + } + + @Test + func `client reports http and json failures`() async throws { + let data = try Self.fixtureData() + let httpClient = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 500))))) + let jsonClient = ModelsDevClient(transport: MockTransport( + result: .success((Data("not json".utf8), Self.response(status: 200))))) + + await #expect(throws: ModelsDevClient.Error.httpStatus(500)) { + _ = try await httpClient.fetchCatalog() + } + await #expect(throws: ModelsDevClient.Error.invalidJSON) { + _ = try await jsonClient.fetchCatalog() + } + } + + private static func fixtureData() throws -> Data { + let url = try #require(Bundle.module.url( + forResource: "models-dev-subset", + withExtension: "json", + subdirectory: "Fixtures")) + return try Data(contentsOf: url) + } + + private static func fixtureCatalog() throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) + } + + private static func catalog(_ json: String) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func cacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-modelsdev-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func response(status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://models.dev/api.json")!, + statusCode: status, + httpVersion: nil, + headerFields: nil)! + } +} + +private enum MockError: Error { + case failed +} + +private struct MockTransport: ModelsDevHTTPTransport { + let result: Result<(Data, URLResponse), Error> + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + try self.result.get() + } +} + +private final class TrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { + private(set) var calls = 0 + let result: Result<(Data, URLResponse), Error> + + init(result: Result<(Data, URLResponse), Error>) { + self.result = result + } + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + self.calls += 1 + return try self.result.get() + } +} diff --git a/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift new file mode 100644 index 000000000..3188f5dd6 --- /dev/null +++ b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Testing + +struct MoonshotSettingsReaderTests { + @Test + func `api key prefers MOONSHOT API KEY`() { + let env = [ + "MOONSHOT_API_KEY": "primary-token", + "MOONSHOT_KEY": "fallback-token", + ] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "primary-token") + } + + @Test + func `api key strips quotes`() { + let env = ["MOONSHOT_KEY": "\"quoted-token\""] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "quoted-token") + } + + @Test + func `region parses china`() { + let env = ["MOONSHOT_REGION": "china"] + + #expect(MoonshotSettingsReader.region(environment: env) == .china) + } + + @Test + func `default settings snapshot does not mask environment region`() { + let settings = ProviderSettingsSnapshot.MoonshotProviderSettings() + + #expect(settings.region == nil) + } + + @Test + func `region defaults to international for unknown values`() { + let env = ["MOONSHOT_REGION": "moon"] + + #expect(MoonshotSettingsReader.region(environment: env) == .international) + } +} + +struct MoonshotProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["MOONSHOT_API_KEY": "env-token"] + let resolution = ProviderTokenResolver.moonshotResolution(environment: env) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `uses kimi branding icon`() { + let branding = MoonshotProviderDescriptor.descriptor.branding + + #expect(branding.iconStyle == .kimi) + #expect(branding.iconResourceName == "ProviderIcon-kimi") + } + + @Test + func `dashboard url opens account console`() { + #expect( + MoonshotProviderDescriptor.descriptor.metadata.dashboardURL + == "https://platform.moonshot.ai/console/account") + } +} diff --git a/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift new file mode 100644 index 000000000..7d69af2af --- /dev/null +++ b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct MoonshotUsageFetcherTests { + @Test + func `parses documented response`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": 12.34 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + + #expect(summary.availableBalance == 49.58) + #expect(summary.voucherBalance == 50.00) + #expect(summary.cashBalance == 12.34) + + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .moonshot) == "Balance: $49.58") + } + + @Test + func `negative cash balance is surfaced as deficit`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": -0.42 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + + #expect(summary.cashBalance == -0.42) + #expect(usage.loginMethod(for: .moonshot)?.contains("in deficit") == true) + } + + @Test + func `invalid root returns parse error`() { + let json = """ + [{ "available_balance": 1 }] + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case MoonshotUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `api code failure returns api error`() { + let json = """ + { + "code": 401, + "data": { + "available_balance": 0, + "voucher_balance": 0, + "cash_balance": 0 + }, + "scode": "unauthorized", + "status": false + } + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "code 401, scode unauthorized" + } + } + + @Test + func `international host uses moonshot ai`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .international) + + #expect(url.absoluteString == "https://api.moonshot.ai/v1/users/me/balance") + } + + @Test + func `china host uses moonshot cn`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .china) + + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + } + + @Test + func `fetch usage sends bearer token and bounded request`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + + let body = """ + { + "code": 0, + "data": { + "available_balance": 9.87, + "voucher_balance": 1.23, + "cash_balance": 8.64 + }, + "scode": "0x0", + "status": true + } + """ + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + let snapshot = try await MoonshotUsageFetcher.fetchUsage( + apiKey: " live-token ", + region: .china, + session: session) + + #expect(MoonshotStubURLProtocol.requests.count == 1) + #expect(snapshot.summary.availableBalance == 9.87) + #expect(snapshot.toUsageSnapshot().loginMethod(for: .moonshot) == "Balance: $9.87") + } + + @Test + func `fetch usage surfaces http failure without leaking body`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(#"{"error":"secret-ish provider body"}"#.utf8)) + } + + await #expect { + _ = try await MoonshotUsageFetcher.fetchUsage( + apiKey: "live-token", + session: session) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "HTTP 401" + } + } +} + +final class MoonshotStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + 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/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 01e7e1c39..e7dde89ff 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -86,6 +86,7 @@ struct OllamaUsageFetcherTests { @Test func `cookie importer defaults to chrome first`() { #expect(OllamaCookieImporter.defaultPreferredBrowsers == [.chrome]) + #expect(OllamaCookieImporter.defaultAllowFallbackBrowsers) } @Test @@ -204,6 +205,21 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Safari Profile") } + @Test + func `cookie selector can fall back to comet secure session cookie`() throws { + let fallback = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Comet Profile"), + ] + + let selected = try OllamaCookieImporter.selectSessionInfoWithFallback( + preferredCandidates: [], + allowFallbackBrowsers: true, + loadFallbackCandidates: { fallback }) + #expect(selected.sourceLabel == "Comet Profile") + } + private static func makeCookie( name: String, value: String, diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift new file mode 100644 index 000000000..ab6dc8a4f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -0,0 +1,280 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPICreditBalanceTests { + private func makeContext(apiKey: String = "sk-test") -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + let env = ["OPENAI_API_KEY": apiKey] + 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) + } + + @Test + func `prefers admin key environment variable`() { + let token = OpenAIAPISettingsReader.apiKey(environment: [ + "OPENAI_API_KEY": "sk-project", + "OPENAI_ADMIN_KEY": "sk-admin", + ]) + + #expect(token == "sk-admin") + } + + @Test + func `parses credit grants balance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "object": "credit_summary", + "total_granted": 25.5, + "total_used": 7.25, + "total_available": 18.25, + "grants": { + "object": "list", + "data": [ + { + "grant_amount": 10.0, + "used_amount": 1.0, + "effective_at": 1690000000, + "expires_at": 1800000000 + } + ] + } + } + """ + + let snapshot = try OpenAIAPICreditBalanceFetcher._parseSnapshotForTesting(Data(json.utf8), now: now) + + #expect(snapshot.totalGranted == 25.5) + #expect(snapshot.totalUsed == 7.25) + #expect(snapshot.totalAvailable == 18.25) + #expect(snapshot.nextGrantExpiry == Date(timeIntervalSince1970: 1_800_000_000)) + } + + @Test + func `maps balance to usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let balance = OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 40, + totalAvailable: 60, + nextGrantExpiry: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: now) + + let usage = balance.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.primary?.resetDescription == "$60.00 available") + #expect(usage.providerCost?.used == 40) + #expect(usage.providerCost?.limit == 100) + #expect(usage.identity?.providerID == .openai) + #expect(usage.identity?.loginMethod == "API balance: $60.00") + } + + @Test + func `falls back to legacy billing when admin usage rejects credentials`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _ in + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { _ in + 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") + } + + @Test + func `preserves admin usage error when legacy fallback also fails`() async { + let usageFailure = OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: "changed") + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _ in throw usageFailure }, + balanceFetcher: { _ in throw OpenAIAPICreditBalanceError.forbidden }) + + do { + _ = try await strategy.fetch(self.makeContext()) + Issue.record("Expected admin usage failure") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @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) + + #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 `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 in + #expect(apiKey == "sk-test") + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + }, + 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.providerCost?.used == 25) + #expect(result.usage.providerCost?.limit == 100) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index b234a2c32..e75da4dc5 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -72,6 +72,24 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(shouldWait == false) } + @Test + func `usage breakdown recovery waits briefly after chart classification error`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-1.0))) + #expect(shouldWait == true) + } + + @Test + func `usage breakdown recovery stops blocking partial snapshots`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-5.0))) + #expect(shouldWait == false) + } + @Test func `probe waits briefly after reaching usage route without email or dashboard signals`() { let now = Date() @@ -216,4 +234,81 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) } + + @Test + func `dashboard requests prefer English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) + let request = OpenAIDashboardFetcher.usageURLRequest(url: url) + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api request carries cookies and English localization`() { + let request = OpenAIDashboardFetcher.dashboardUsageAPIRequest(cookieHeader: "a=b") + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `identity api request carries cookies and English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(url: url, cookieHeader: "a=b") + + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/me") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api data maps language independent rate limits and credits`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 34, + "reset_at": 1700604800, + "limit_window_seconds": 604800 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 42.5 + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + let data = OpenAIDashboardFetcher.dashboardAPIData(from: response) + + #expect(data.primaryLimit?.usedPercent == 12) + #expect(data.primaryLimit?.windowMinutes == 300) + #expect(data.secondaryLimit?.usedPercent == 34) + #expect(data.secondaryLimit?.windowMinutes == 10080) + #expect(data.creditsRemaining == 42.5) + #expect(data.accountPlan == "pro") + #expect(data.hasUsageData) + } + + @Test + func `find first email searches nested api payloads`() { + let json = """ + { + "accounts": [ + { "profile": { "name": "Test" } }, + { "profile": { "email": "nested@example.com" } } + ] + } + """ + + #expect(OpenAIDashboardFetcher.findFirstEmail(inJSONData: Data(json.utf8)) == "nested@example.com") + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift new file mode 100644 index 000000000..b6547e14f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIDashboardModelsTests { + @Test + func `removes skill usage services from usage breakdown`() { + let breakdown = [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 7), + OpenAIDashboardServiceUsage(service: " skillusage:github:github ", creditsUsed: 2), + ], + totalCreditsUsed: 19), + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [ + OpenAIDashboardServiceUsage(service: "Skillusage:deep Research", creditsUsed: 3), + ], + totalCreditsUsed: 3), + ] + + let filtered = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) + + #expect(filtered == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + ], + totalCreditsUsed: 10), + ]) + } + + @Test + func `snapshot initializer sanitizes usage breakdown`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + OpenAIDashboardServiceUsage(service: "Skillusage:pdf Renderer", creditsUsed: 6), + ], + totalCreditsUsed: 10), + ], + creditsPurchaseURL: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + ], + totalCreditsUsed: 4), + ]) + } + + @Test + func `snapshot decoder drops empty zero usage buckets`() throws { + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [ + { "day": "2026-04-30", "services": [], "totalCreditsUsed": 0 }, + { "day": "2026-04-29", "services": [], "totalCreditsUsed": 4 } + ], + "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.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [], + totalCreditsUsed: 4), + ]) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index 3fbe44675..acb41ae82 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -84,6 +84,17 @@ struct OpenAIDashboardParserTests { #expect(limits.secondary?.windowMinutes == 10080) } + @Test + func `parses spaced five hour limit label`() { + let body = """ + Limite 5 h + 72 % restant + """ + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + #expect(abs((limits.primary?.usedPercent ?? 0) - 28) < 0.001) + #expect(limits.primary?.windowMinutes == 300) + } + @Test func `parses plan from client bootstrap`() { let html = """ @@ -109,7 +120,7 @@ struct OpenAIDashboardParserTests { """ - #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Pro Lite") + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Pro 5x") } @Test @@ -126,6 +137,26 @@ struct OpenAIDashboardParserTests { #expect(abs((events.last?.creditsUsed ?? 0) - 506.235) < 0.0001) } + @Test + func `parses credit event amount with localized credit label`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "397,205 crédits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(abs((events.first?.creditsUsed ?? 0) - 397.205) < 0.0001) + } + + @Test + func `parses credit event amount with english comma thousands`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "1,234 credits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(events.first?.creditsUsed == 1234) + } + @Test func `builds daily breakdown from events`() throws { let calendar = Calendar(identifier: .gregorian) diff --git a/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift new file mode 100644 index 000000000..5ed6b621c --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift @@ -0,0 +1,186 @@ +#if os(macOS) +import Foundation +import Testing +import WebKit +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct OpenAIDashboardScrapeScriptTests { + @Test + func `usage breakdown scraper ignores neighboring client charts`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.multiChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + let debug = dict["usageBreakdownDebug"] as? String + let raw = try #require(dict["usageBreakdownJSON"] as? String, "debug: \(debug ?? "nil")") + let decoded = try JSONDecoder().decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + + #expect(decoded.count == 1) + #expect(decoded.first?.day == "2026-05-01") + #expect(decoded.first?.totalCreditsUsed == 30) + #expect((decoded.first?.services.map(\.service) ?? []) == ["Desktop", "CLI"]) + } + + @Test + func `usage breakdown scraper reports wrong chart instead of accepting it`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.clientOnlyChartHTML, baseURL: nil) + try await Self.waitForFixture(webView, elementID: "client-chart") + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect((dict["usageBreakdownError"] as? String)?.contains("Threads and turns by client") == true) + } + + @Test + func `usage breakdown scraper rejects non english chart titles`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.localizedUsageChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect( + (dict["usageBreakdownError"] as? String)? + .contains("No English usage breakdown chart title found") == true) + } + + private static func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + + private static func waitForFixture(_ webView: WKWebView, elementID: String = "usage-chart") async throws { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let loaded = try? await webView.evaluateJavaScript( + "document.getElementById('\(elementID)') !== null") as? Bool + if loaded == true { return } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private static let multiChartHTML = """ + + +
+

Usage breakdown

+
+

Personal usage

+
+ Daily threads by client + + + + + +
+
+

Product activity

+ + + Daily threads by client + + + + + +
+
+

Tokens by model

+ + + + + +
+ + + + """ + + private static let clientOnlyChartHTML = """ + + +
+

Threads and turns by client

+ + + + + +
+ + + + """ + + private static let localizedUsageChartHTML = """ + + +
+

Desglose de uso

+ + + + + +
+ + + + """ +} +#endif diff --git a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift new file mode 100644 index 000000000..a73d650b9 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenCodeGoMenuCardModelTests { + @Test + func `zen balance renders as optional balance`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Zen balance") + #expect(model.providerCost?.spendLine == "Balance: $98.76") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + @Test + func `zen balance hides when optional usage is disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost == nil) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 73876e68f..7929ae3bf 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -1,9 +1,22 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore @Suite(.serialized) struct OpenCodeGoUsageFetcherErrorTests { + @Test + func `dashboard URL uses normalized workspace ID`() { + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "https://opencode.ai/workspace/wrk_abc123/go") + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "workspace=wrk_def456") + .absoluteString == "https://opencode.ai/workspace/wrk_def456/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: nil) + .absoluteString == "https://opencode.ai") + } + private struct UsageWindow { let percent: Double let resetInSec: Int @@ -15,6 +28,21 @@ struct OpenCodeGoUsageFetcherErrorTests { return URLSession(configuration: config) } + @Test + func `redirect guard allows only same-host https redirects`() { + #expect(OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://opencode.ai/workspace/wrk_TEST123/go"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://evil.example/steal"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "http://opencode.ai/insecure"))) + } + @Test func `extracts api error from detail field`() async throws { defer { @@ -97,7 +125,7 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(snapshot.rollingUsagePercent == 22) #expect(snapshot.weeklyUsagePercent == 44) #expect(snapshot.monthlyUsagePercent == 55) - #expect(methods == ["GET", "POST", "GET"]) + #expect(methods == ["GET", "POST", "GET", "GET"]) } @Test @@ -183,10 +211,10 @@ struct OpenCodeGoUsageFetcherErrorTests { OpenCodeGoStubURLProtocol.handler = nil } - var observedPath: String? + var observedPaths: [String] = [] OpenCodeGoStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } - observedPath = url.path + observedPaths.append(url.path) return Self.makeResponse( url: url, body: Self.goUsagePageHTML( @@ -204,7 +232,214 @@ struct OpenCodeGoUsageFetcherErrorTests { workspaceIDOverride: "https://opencode.ai/workspace/wrk_URL123/billing", session: self.makeSession()) - #expect(observedPath == "/workspace/wrk_URL123/go") + #expect(observedPaths == ["/workspace/wrk_URL123/go", "/workspace/wrk_URL123"]) + } + + @Test + func `fetcher attaches optional zen balance from workspace root`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.zenBalanceUSD == 98.76) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") + } + + @Test + func `optional zen balance failure does not fail subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var rootTimeout: TimeInterval? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootTimeout = request.timeoutInterval + throw URLError(.timedOut) + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(rootTimeout == 5) + } + + @Test + func `optional zen balance does not stall subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + Thread.sleep(forTimeInterval: 1) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let start = ContinuousClock.now + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let elapsed = start.duration(to: ContinuousClock.now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(elapsed < .milliseconds(700)) + } + + @Test + func `optional zen balance can be skipped by settings`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedPaths: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(observedPaths == ["/workspace/wrk_TEST123/go"]) + } + + @Test + func `optional zen balance cancellation propagates`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let rootStarted = AsyncStream.makeStream(of: Void.self) + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootStarted.continuation.yield(()) + Thread.sleep(forTimeInterval: 0.2) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let task = Task { + try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + } + + let started = await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = rootStarted.stream.makeAsyncIterator() + return await iterator.next() != nil + } + group.addTask { + try? await Task.sleep(for: .seconds(2)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + #expect(started) + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation to propagate.") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got: \(error)") + } } @Test diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift index d2bcfd170..e7e7af0da 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -31,6 +31,51 @@ struct OpenCodeGoUsageParserTests { #expect(snapshot.monthlyResetInSec == 880_201) } + @Test + func `parses zen balance from workspace page text`() { + let text = """ +
+

現在の残高 $1,234.56

+

Claude Opus and GPT-5 models enabled

+
+ """ + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1234.56) + } + + @Test + func `parses zen balance from nested JSON`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceEnabled": true, + "zenBalance": "1,042.75", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1042.75) + } + + @Test + func `zen balance parser ignores metadata before amount`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceUpdatedAt": 1_800_000_000, + "balanceRefreshInterval": 60, + "zenBalance": "42.50", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 42.50) + } + @Test func `parses subscription usage from live go page hydration`() throws { let rollingResetInSec = 17591 @@ -121,6 +166,42 @@ struct OpenCodeGoUsageParserTests { #expect(usage.tertiary == nil) } + @Test + func `snapshot exposes zen balance as provider cost`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: false, + rollingUsagePercent: 10, + weeklyUsagePercent: 20, + monthlyUsagePercent: 0, + rollingResetInSec: 600, + weeklyResetInSec: 3600, + monthlyResetInSec: 0, + zenBalanceUSD: 12.34, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.providerCost?.period == "Zen balance") + #expect(usage.providerCost?.used == 12.34) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.currencyCode == "USD") + } + + @Test + func `zen balance parser ignores balance flags without amounts`() throws { + let payload: [String: Any] = [ + "billing": [ + "balanceEnabled": true, + "useBalance": false, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == nil) + } + @Test func `parses subscription from nested candidate windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index d7beb9054..c81141498 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -133,7 +133,16 @@ struct OpenRouterUsageStatsTests { let body = #"{"data":{"total_credits":100,"total_usage":40}}"# return Self.makeResponse(url: url, body: body, statusCode: 200) case "/api/v1/key": - let body = #"{"data":{"limit":20,"usage":0.5,"rate_limit":{"requests":120,"interval":"10s"}}}"# + let body = #""" + {"data":{ + "limit":20, + "usage":0.5, + "usage_daily":0.12, + "usage_weekly":0.74, + "usage_monthly":4.56, + "rate_limit":{"requests":120,"interval":"10s"} + }} + """# return Self.makeResponse(url: url, body: body, statusCode: 200) default: return Self.makeResponse(url: url, body: "{}", statusCode: 404) @@ -153,6 +162,9 @@ struct OpenRouterUsageStatsTests { #expect(usage.keyDataFetched) #expect(usage.keyLimit == 20) #expect(usage.keyUsage == 0.5) + #expect(usage.keyUsageDaily == 0.12) + #expect(usage.keyUsageWeekly == 0.74) + #expect(usage.keyUsageMonthly == 4.56) #expect(usage.keyRemaining == 19.5) #expect(usage.keyUsedPercent == 2.5) #expect(usage.keyQuotaStatus == .available) @@ -199,6 +211,9 @@ struct OpenRouterUsageStatsTests { keyDataFetched: true, keyLimit: nil, keyUsage: nil, + keyUsageDaily: 0.12, + keyUsageWeekly: 0.74, + keyUsageMonthly: 4.56, rateLimit: nil, updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) let snapshot = openRouter.toUsageSnapshot() @@ -209,6 +224,9 @@ struct OpenRouterUsageStatsTests { #expect(decoded.openRouterUsage?.keyDataFetched == true) #expect(decoded.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(decoded.openRouterUsage?.keyUsageDaily == 0.12) + #expect(decoded.openRouterUsage?.keyUsageWeekly == 0.74) + #expect(decoded.openRouterUsage?.keyUsageMonthly == 4.56) } private static func makeResponse( diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 2c9963f12..10098bf33 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore struct PathBuilderTests { @Test @@ -44,6 +44,61 @@ struct PathBuilderTests { #expect(async == sync) } + @Test + func `shell runner drains noisy stdout and stderr`() throws { + let script = """ + i=0 + while [ "$i" -lt 4000 ]; do + printf 'out-%04d\\n' "$i" + printf 'err-%04d\\n' "$i" >&2 + i=$((i + 1)) + done + printf '__CODEXBAR_DONE__\\n' + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 4.0)) + let output = try #require(String(data: data, encoding: .utf8)) + + #expect(output.contains("out-3999")) + #expect(output.contains("__CODEXBAR_DONE__")) + } + + @Test + func `shell runner terminates background children after normal exit`() throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-shell-runner-\(UUID().uuidString)") + .path + let escapedMarker = Self.shellSingleQuoted(marker) + let script = """ + ( + trap '' TERM + touch \(escapedMarker) + while :; do sleep 1; done + ) & + printf '%s\\n' "$!" + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 2.0)) + let pidText = try #require(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) + let pid = try #require(pid_t(pidText)) + + defer { + kill(pid, SIGKILL) + try? FileManager.default.removeItem(atPath: marker) + } + + let deadline = Date().addingTimeInterval(2.0) + while kill(pid, 0) == 0, Date() < deadline { + usleep(50000 as useconds_t) + } + + #expect(kill(pid, 0) != 0) + } + @Test func `resolves codex from env override`() { let overridePath = "/custom/bin/codex" @@ -317,11 +372,15 @@ struct PathBuilderTests { } @Test - func `prefers shell PATH over well-known paths`() { + func `prefers well-known paths over interactive shell lookup`() { let shellPath = "/custom/bin/claude" let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" let fm = MockFileManager(executables: [shellPath, cmuxPath]) - let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in shellPath } + var shellLookupCalled = false + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in + shellLookupCalled = true + return shellPath + } let resolved = BinaryLocator.resolveClaudeBinary( env: ["SHELL": "/bin/zsh"], @@ -329,7 +388,8 @@ struct PathBuilderTests { commandV: commandV, fileManager: fm, home: "/Users/test") - #expect(resolved == shellPath) + #expect(!shellLookupCalled) + #expect(resolved == cmuxPath) } @Test @@ -356,6 +416,10 @@ struct PathBuilderTests { #expect(!aliasCalled) #expect(resolved == path) } + + private static func shellSingleQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } } private final class MockFileManager: FileManager { diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index 881192264..1a063ce78 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -419,6 +419,196 @@ struct PiSessionCostScannerTests { #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } + @Test + func `pi scanner preserves per-message threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) + let model = "claude-sonnet-4-6" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-05-09T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + let expectedRequestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let expectedCost = expectedRequestCost * 2 + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) + } + + @Test + func `pi scanner ignores v1 cache missing usage sample counts`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let model = "claude-sonnet-4-6" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + let fileURL = try env.writePiSessionFile( + relativePath: "2026-05-10T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let mtime = try #require(attrs[.modificationDate] as? Date) + let size = try #require((attrs[.size] as? NSNumber)?.int64Value) + + let requestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregatePacked = PiPackedUsage( + inputTokens: 300_000, + totalTokens: 300_000, + costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), + costSampleCount: 2, + usageSampleCount: nil) + let dayKey = "2026-05-10" + let contributions = [ + UsageProvider.claude.rawValue: [ + dayKey: [ + model: aggregatePacked, + ], + ], + ] + let oldFileUsage = PiSessionFileUsage( + mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), + size: size, + parsedBytes: size, + lastModelContext: nil, + contributions: contributions) + var oldCache = PiSessionCostCache(version: 1) + oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + oldCache.scanSinceKey = dayKey + oldCache.scanUntilKey = dayKey + oldCache.daysByProvider = contributions + oldCache.files = [fileURL.path: oldFileUsage] + let oldCacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v1.json", isDirectory: false) + try FileManager.default.createDirectory( + at: oldCacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(oldCache).write(to: oldCacheURL) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + + let expectedCost = requestCost * 2 + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + + let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] + #expect(newCache.version == 2) + #expect(rebuilt?.usageSampleCount == 2) + #expect(rebuilt?.costSampleCount == 2) + } + @Test func `pi scanner reparses unchanged cached file when scan window expands`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index b71448ef1..a7b19b7bd 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct PreferencesPaneSmokeTests { @Test func `builds preference panes with default settings`() { @@ -25,7 +26,7 @@ struct PreferencesPaneSmokeTests { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true settings.menuBarShowsHighestUsage = true - settings.showAllTokenAccountsInMenu = true + settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true settings.debugDisableKeychainAccess = true @@ -43,6 +44,41 @@ struct PreferencesPaneSmokeTests { _ = AboutPane(updater: DisabledUpdaterController()).body } + @Test + func `overview provider limit text formats numeric limit as object argument`() { + let text = DisplayPane.overviewProviderLimitText(limit: 3) + + #expect(text.contains("3")) + #expect(!text.contains("%@")) + } + + @Test + func `language preference updates global localization resolver`() { + 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") + } + } + + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language") + + settings.appLanguage = "zh-Hans" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "zh-Hans") + #expect(L("tab_general") == "通用") + #expect(L("quota_warning_notifications_title") == "配额预警通知") + #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift new file mode 100644 index 000000000..a1923a4ab --- /dev/null +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ProviderChangelogLinkTests { + @Test + func `known CLI providers declare changelog URLs`() { + let metadata = ProviderDefaults.metadata + + #expect(metadata[.codex]?.changelogURL == "https://github.com/openai/codex/releases") + #expect(metadata[.claude]?.changelogURL == "https://github.com/anthropics/claude-code/releases") + #expect(metadata[.gemini]?.changelogURL == "https://github.com/google-gemini/gemini-cli/releases") + } + + @Test + func `provider menu hides changelog action until enabled`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex-default") + #expect(!self.actionTitles(from: codexDescriptor).contains("Changelog")) + } + + @Test + func `provider menu shows changelog action only when setting and URL are present`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex", + changelogLinksEnabled: true) + #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) + + let openRouterDescriptor = self.makeDescriptor( + provider: .openrouter, + suite: "ProviderChangelogLinkTests-openrouter", + changelogLinksEnabled: true) + #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) + } + + private func makeDescriptor( + provider: UsageProvider, + suite: String, + changelogLinksEnabled: Bool = false) -> MenuDescriptor + { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.providerChangelogLinksEnabled = changelogLinksEnabled + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + return MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: true) + } + + private func actionTitles(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .action(title, _) = entry else { return nil } + return title + } + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 665bf0c76..d7f42835d 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -39,6 +39,113 @@ struct ProviderConfigEnvironmentTests { #expect(env[OpenRouterSettingsReader.envKey] == "or-token") } + @Test + func `applies API key override for doubao`() { + let config = ProviderConfig(id: .doubao, apiKey: "db-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "db-token") + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") + } + + func `applies API key override for moonshot`() { + let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .moonshot, + config: config) + + let key = MoonshotSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "moon-token") + } + + @Test + func `applies API key override for elevenlabs`() { + let config = ProviderConfig(id: .elevenlabs, apiKey: "xi-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .elevenlabs, + config: config) + + #expect(env[ElevenLabsSettingsReader.apiKeyEnvironmentKey] == "xi-token") + #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") + } + + @Test + func `openai config override uses preferred admin key environment`() { + let config = ProviderConfig(id: .openai, apiKey: "config-openai-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "env-api-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "config-openai-token") + #expect(env[OpenAIAPISettingsReader.apiKeyEnvironmentKey] == "env-api-token") + #expect(ProviderTokenResolver.openAIAPIToken(environment: env) == "config-openai-token") + } + + @Test + func `bedrock config maps AWS credential fields`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + cookieHeader: "legacy-cookie-secret", + region: "us-west-2") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "us-west-2") + #expect(!env.values.contains("legacy-cookie-secret")) + } + + @Test + func `bedrock config merges secret and region without replacing environment access key`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: nil, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [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.hasCredentials(environment: env)) + } + + @Test + func `ignores legacy API key override for deepseek`() { + let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepseek, + config: config) + + let key = DeepSeekSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == nil) + #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") @@ -63,6 +170,84 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token") } + @Test + func `deepseek config override leaves environment token alone`() { + let config = ProviderConfig(id: .deepseek, apiKey: "config-token") + let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [envKey: "env-token"], + provider: .deepseek, + config: config) + + #expect(env[envKey] == "env-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "env-token") + } + + @Test + func `applies API key override for codebuff`() { + let config = ProviderConfig(id: .codebuff, apiKey: "cb-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "cb-config-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "cb-config-token") + } + + @Test + func `applies API key override for deepgram`() { + let config = ProviderConfig(id: .deepgram, apiKey: "dg-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.apiKeyEnvironmentKey] == "dg-token") + #expect(ProviderTokenResolver.deepgramResolution( + type: .apiKey, + environment: env) + == "dg-token") + } + + @Test + func `applies Deepgram project ID override from provider config`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "proj-123") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "proj-123") + } + + @Test + func `Deepgram project ID config overrides environment`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "config-project") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [DeepgramSettingsReader.projectIDEnvironmentKey: "env-project"], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "config-project") + } + + @Test + func `codebuff config override leaves environment token alone`() { + let config = ProviderConfig(id: .codebuff, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [CodebuffSettingsReader.apiTokenKey: "env-token"], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "env-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "env-token") + } + @Test func `leaves environment when API key missing`() { let config = ProviderConfig(id: .zai, apiKey: nil) diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift new file mode 100644 index 000000000..40a794d70 --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ProviderHTTPClientTests { + @Test + func `default client configuration fails blocked connections promptly`() { + let configuration = ProviderHTTPClient.defaultConfiguration() + + #expect(configuration.timeoutIntervalForRequest == 30) + #expect(configuration.timeoutIntervalForResource == 90) + #if !os(Linux) + #expect(configuration.waitsForConnectivity == false) + #endif + } + + @Test + func `client loads requests through an injected session`() async throws { + StubURLProtocol.requests = [] + StubURLProtocol.handler = { request in + StubURLProtocol.requests.append(request) + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(#"{"ok":true}"#.utf8), response) + } + defer { + StubURLProtocol.handler = nil + StubURLProtocol.requests = [] + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + let client = ProviderHTTPClient(session: URLSession(configuration: configuration)) + let request = try URLRequest(url: #require(URL(string: "https://example.com/status"))) + + let (data, response) = try await client.data(for: request) + + let body = try #require(String(data: data, encoding: .utf8)) + #expect(body == #"{"ok":true}"#) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(StubURLProtocol.requests.count == 1) + #expect(StubURLProtocol.requests.first?.url?.host == "example.com") + } + + @Test + func `response helper unwraps HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 204, + httpVersion: "HTTP/1.1", + headerFields: ["X-Test": "ok"])! + return (Data("done".utf8), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/ok"))) + + let response = try await transport.response(for: request) + + #expect(response.statusCode == 204) + #expect(response.response.value(forHTTPHeaderField: "X-Test") == "ok") + #expect(String(data: response.data, encoding: .utf8) == "done") + } + + @Test + func `response helper rejects non HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = URLResponse( + url: request.url ?? URL(string: "https://example.com/not-http")!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) + return (Data(), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/not-http"))) + + await #expect(throws: URLError.self) { + _ = try await transport.response(for: request) + } + } +} + +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, URLResponse))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.cannotLoadFromNetwork)) + return + } + + do { + let (data, response) = 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/ProviderHTTPTransportStub.swift b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift new file mode 100644 index 000000000..1c01e75cc --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import CodexBarCore + +actor ProviderHTTPTransportStub: ProviderHTTPTransport { + private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + private var recordedRequests: [URLRequest] = [] + + init(handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { + self.handler = handler + } + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + return try await self.handler(request) + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7189d0c2a..967407607 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -22,6 +22,12 @@ struct ProviderIconResourcesTests { "antigravity", "factory", "copilot", + "crof", + "commandcode", + "kimi", + "bedrock", + "elevenlabs", + "deepgram", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift index 909306581..3ca12b233 100644 --- a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift +++ b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift @@ -12,4 +12,13 @@ struct ProviderMetadataStatusLinkTests { "Expected \(provider.rawValue) statusLinkURL to be \(expected)") } } + + @Test + func `kimi K2 metadata does not present legacy endpoint as official`() throws { + let meta = try #require(ProviderDefaults.metadata[.kimik2]) + + #expect(meta.displayName == "Kimi K2 (unofficial)") + #expect(meta.toggleTitle == "Show unofficial Kimi K2 usage") + #expect(meta.dashboardURL == nil) + } } diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 81a159b8e..a2b23f683 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -59,7 +59,8 @@ struct ProviderSettingsDescriptorTests { lastRunAtByID.removeValue(forKey: id) } }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) let impl = try #require(ProviderCatalog.implementation(for: provider)) let toggles = impl.settingsToggles(context: context) @@ -115,7 +116,8 @@ struct ProviderSettingsDescriptorTests { setStatusText: { _, _ in }, lastAppActiveRunAt: { _ in nil }, setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) @@ -209,7 +211,8 @@ struct ProviderSettingsDescriptorTests { setStatusText: { _, _ in }, lastAppActiveRunAt: { _ in nil }, setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ 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" })) @@ -258,7 +261,8 @@ struct ProviderSettingsDescriptorTests { setStatusText: { _, _ in }, lastAppActiveRunAt: { _ in nil }, setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -300,7 +304,8 @@ struct ProviderSettingsDescriptorTests { setStatusText: { _, _ in }, lastAppActiveRunAt: { _ in nil }, setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -362,7 +367,8 @@ struct ProviderSettingsDescriptorTests { setStatusText: { _, _ in }, lastAppActiveRunAt: { _ in nil }, setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + requestConfirmation: { _ in }, + runLoginFlow: {}) let implementation = KiloProviderImplementation() let toggles = implementation.settingsToggles(context: context) @@ -374,6 +380,54 @@ struct ProviderSettingsDescriptorTests { #expect(fields.contains(where: { $0.id == "kilo-api-key" })) } + @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 implementation = DeepgramProviderImplementation() + let fields = implementation.settingsFields(context: context) + + #expect(fields.contains(where: { $0.id == "deepgram-api-key" })) + #expect(fields.contains(where: { $0.id == "deepgram-project-id" })) + + // Basic presence checks for Deepgram settings fields (layout copied from OpenRouter) + _ = try #require(fields.first(where: { $0.id == "deepgram-project-id" })) + _ = try #require(fields.first(where: { $0.id == "deepgram-api-key" })) + } + @Test func `alibaba presentation follows store source label`() throws { let suite = "ProviderSettingsDescriptorTests-alibaba-presentation" diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift new file mode 100644 index 000000000..0a903e95e --- /dev/null +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -0,0 +1,410 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ProviderStorageFootprintTests { + @Test + func `scanner sums nested regular files and skips symlink targets`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let nested = root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data(repeating: 1, count: 5).write(to: root.appendingPathComponent("a.jsonl")) + try Data(repeating: 2, count: 7).write(to: nested.appendingPathComponent("b.jsonl")) + + let external = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: external) } + let target = external.appendingPathComponent("outside.bin") + try Data(repeating: 3, count: 100).write(to: target) + let link = root.appendingPathComponent("linked.bin") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [root.path]) + + #expect(footprint.totalBytes == 12) + #expect(footprint.paths == [root.path]) + #expect(footprint.missingPaths.isEmpty) + #expect(footprint.components.map(\.name) == ["nested", "a.jsonl"]) + #expect(footprint.components.map(\.totalBytes) == [7, 5]) + } + + @Test + func `scanner does not follow symlinked candidate roots`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let target = root.appendingPathComponent("target", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try Data(repeating: 1, count: 64).write(to: target.appendingPathComponent("session.jsonl")) + let symlinkRoot = root.appendingPathComponent("codex-link", isDirectory: true) + try FileManager.default.createSymbolicLink(at: symlinkRoot, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [symlinkRoot.path]) + + #expect(footprint.paths == [symlinkRoot.path]) + #expect(footprint.totalBytes == 0) + #expect(footprint.components.isEmpty) + } + + @Test + func `scanner records missing paths without failing`() throws { + let root = try Self.makeTemporaryDirectory() + let missing = root.appendingPathComponent("missing") + defer { try? FileManager.default.removeItem(at: root) } + + let footprint = ProviderStorageScanner().scan(provider: .claude, candidatePaths: [missing.path]) + + #expect(footprint.totalBytes == 0) + #expect(footprint.paths.isEmpty) + #expect(footprint.missingPaths == [missing.path]) + } + + @Test + func `codex path catalog uses CODEX_HOME and managed homes`() { + let managed = ManagedCodexAccount( + id: UUID(), + email: "user@example.com", + managedHomePath: "/tmp/codex-managed-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + + let paths = ProviderStoragePathCatalog.candidatePaths( + for: .codex, + environment: ["CODEX_HOME": "/tmp/codex-home"], + managedCodexAccounts: [managed]) + + #expect(paths == ["/tmp/codex-home", "/tmp/codex-managed-home"]) + } + + @Test + func `codex path catalog falls back to default home`() { + let paths = ProviderStoragePathCatalog.candidatePaths(for: .codex, environment: [:]) + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .path + + #expect(paths.first == expected) + } + + @Test + func `claude recommendations use documented cleanup categories`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 28, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + .init(path: "\(root)/file-history", totalBytes: 8), + .init(path: "\(root)/paste-cache", totalBytes: 6), + .init(path: "\(root)/settings.json", totalBytes: 4), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/projects", + "\(root)/file-history", + "\(root)/paste-cache", + ]) + #expect(recommendations[0].consequence.contains("resume")) + #expect(recommendations.allSatisfy { $0.riskLevel == .manualCleanup }) + } + + @Test + func `codex recommendations stay under known homes and exclude auth and config`() { + let root = "/Users/test/.codex" + let footprint = ProviderStorageFootprint( + provider: .codex, + totalBytes: 51, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/sessions", totalBytes: 20), + .init(path: "\(root)/archived_sessions", totalBytes: 15), + .init(path: "\(root)/log", totalBytes: 12), + .init(path: "\(root)/logs_2.sqlite", totalBytes: 11), + .init(path: "\(root)/cache", totalBytes: 10), + .init(path: "\(root)/shell_snapshots", totalBytes: 9), + .init(path: "\(root)/auth.json", totalBytes: 4), + .init(path: "\(root)/config.toml", totalBytes: 2), + .init(path: "/tmp/outside/sessions", totalBytes: 99), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/sessions", + "\(root)/archived_sessions", + "\(root)/cache", + "\(root)/log", + "\(root)/logs_2.sqlite", + "\(root)/shell_snapshots", + ]) + #expect(recommendations.map(\.bytes) == [20, 15, 10, 12, 11, 9]) + } + + @Test + func `unknown provider storage returns no cleanup recommendations`() { + let footprint = ProviderStorageFootprint( + provider: .gemini, + totalBytes: 10, + paths: ["/Users/test/.gemini"], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "/Users/test/.gemini/cache", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(footprint.cleanupRecommendations.isEmpty) + } + + @Test + @MainActor + func `overview row carries storage text outside provider detail model`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: nil) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + 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)) + + let overview = OverviewMenuCardRowView(model: model, storageText: "1.5 GB", width: 310) + let detail = UsageMenuCardView(model: model, width: 310) + + #expect(overview.storageText == "1.5 GB") + #expect(detail.model.provider == UsageProvider.claude) + } + + @Test + @MainActor + func `storage detail view exposes cleanup recommendations while overview remains number only`() throws { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 10, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + 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: Date(timeIntervalSince1970: 0))) + let overview = OverviewMenuCardRowView(model: model, storageText: "10 B", width: 310) + + #expect(detailView.cleanupRecommendations.map(\.path) == ["\(root)/projects"]) + #expect(overview.storageText == "10 B") + } + + @Test + @MainActor + func `storage detail view exposes copyable exact paths`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 110, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 100), + .init(path: "\(root)/file-history", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + + #expect(detailView.copyablePaths.contains("\(root)/projects")) + #expect(detailView.copyablePaths.contains("\(root)/file-history")) + } + + @Test + @MainActor + func `storage path copy button writes exact path to pasteboard`() { + let path = "/Users/test/.claude/projects/example" + StoragePathCopyButton.copyToPasteboard(path) + + #expect(NSPasteboard.general.string(forType: .string) == path) + } + + @Test + @MainActor + func `manual storage refresh updates deleted provider data`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-refresh-\(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()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + try FileManager.default.removeItem(at: sessions) + await store.refreshStorageFootprintsForOverviewNow() + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 0) + #expect(store.storageFootprintText(for: .codex) == "No local data found") + } + + @Test + @MainActor + func `storage refresh is opt in and clears stale footprints when disabled`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: codexHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-opt-in-\(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()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + + settings.providerStorageFootprintsEnabled = true + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + settings.providerStorageFootprintsEnabled = false + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + #expect(store.providerStorageFootprints.isEmpty) + } + + @Test + @MainActor + func `forced scheduled storage refresh does not restart identical in flight scan`() throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + + let suite = "ProviderStorageFootprintTests-storage-in-flight-\(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()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.storageRefreshGeneration = 41 + store.storageRefreshInFlightSignature = "codex=\(codexHome.path)" + store.storageRefreshTask = Task.detached { + try? await Task.sleep(for: .seconds(30)) + } + defer { + store.storageRefreshTask?.cancel() + store.storageRefreshTask = nil + store.storageRefreshInFlightSignature = nil + } + + store.scheduleStorageFootprintRefresh(for: [.codex], force: true) + + #expect(store.storageRefreshGeneration == 41) + } + + private static func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 6567cd240..9477d0c31 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -40,6 +40,20 @@ struct ProviderTokenResolverTests { #expect(resolution == nil) } + @Test + func `doubao resolution uses first supported environment token`() { + let env = ["ARK_API_KEY": "ark-token"] + let resolution = ProviderTokenResolver.doubaoResolution(environment: env) + #expect(resolution?.token == "ark-token") + #expect(resolution?.source == .environment) + } + + @Test + func `doubao settings reader trims quoted token`() { + let env = ["DOUBAO_API_KEY": " 'doubao-token' "] + #expect(DoubaoSettingsReader.apiKey(environment: env) == "doubao-token") + } + @Test func `kilo resolution prefers environment over auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) @@ -80,4 +94,53 @@ struct ProviderTokenResolverTests { try contents.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } + + @Test + func `codebuff resolution prefers environment over credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let env = [CodebuffSettingsReader.apiTokenKey: "env-token"] + let resolution = ProviderTokenResolver.codebuffResolution( + environment: env, + authFileURL: fileURL) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `codebuff resolution falls back to credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token","fingerprintId":"fp"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + + #expect(resolution?.token == "file-token") + #expect(resolution?.source == .authFile) + } + + @Test + func `codebuff resolution returns nil for malformed credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile(contents: #"{not-json}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + #expect(resolution == nil) + } + + private func makeCodebuffCredentialsFile(contents: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("credentials.json", isDirectory: false) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index 33a67187f..ee60e2217 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -14,6 +14,19 @@ struct ProvidersPaneCoverageTests { ProvidersPaneTestHarness.exercise(settings: settings, store: store) } + @Test + func `claude token account descriptor shows organization field`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-org-field") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let claudeDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .claude)) + #expect(claudeDescriptor.showsOrganizationField) + + let copilotDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .copilot)) + #expect(!copilotDescriptor.showsOrganizationField) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") @@ -31,6 +44,58 @@ struct ProvidersPaneCoverageTests { ]) } + @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) + + 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.") + } + + @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.") + } + + @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.") + } + @Test func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker") @@ -104,6 +169,29 @@ struct ProvidersPaneCoverageTests { #expect(option?.title == "Extra usage") } + @Test + func `claude menu bar metric picker includes extra usage when spend limit is available`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()), + provider: .claude) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .claude) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + @Test func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") @@ -158,6 +246,14 @@ struct ProvidersPaneCoverageTests { #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") + + #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") diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift new file mode 100644 index 000000000..2f7f2b3b2 --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -0,0 +1,126 @@ +import Testing +@testable import CodexBar + +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.") + } + + @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.") + } + + @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") + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + } + + @Test + func `does nothing without crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 60, + currentRemaining: 55, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == nil) + } + + @Test + func `detects downward crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 50) + } + + @Test + func `skips already fired thresholds`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: [50]) + + #expect(crossed == nil) + } + + @Test + func `chooses most severe threshold when crossing several at once`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 80, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `startup below threshold warns once at most severe threshold`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: nil, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `warning marks threshold and higher thresholds fired`() { + let fired = QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: 20, + thresholds: [50, 20]) + + #expect(fired == [50, 20]) + } + + @Test + func `recovery clears only thresholds below current remaining`() { + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: 30, + alreadyFired: [50, 20]) + + #expect(cleared == [20]) + } + + @Test + func `zero threshold does not post quota warning`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 10, + currentRemaining: 0, + thresholds: [10, 0], + alreadyFired: [10]) + + #expect(crossed == nil) + #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift new file mode 100644 index 000000000..7cdbf152f --- /dev/null +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation +import XCTest + +final class ResetTimeBackfillTests: XCTestCase { + func test_backfillsMissingResetMetadataFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: "Resets in 1h", + nextRegenPercent: 9) + let fresh = RateWindow( + usedPercent: 62, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 4) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.usedPercent, 62) + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + XCTAssertEqual(result.resetDescription, "Resets in 1h") + XCTAssertEqual(result.nextRegenPercent, 4) + } + + func test_skipsExpiredCachedReset() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: "Expired") + let fresh = RateWindow(usedPercent: 62, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertNil(result.resetsAt) + XCTAssertNil(result.windowMinutes) + XCTAssertNil(result.resetDescription) + } + + func test_snapshotBackfillPreservesCurrentSnapshotFields() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "peter@example.com", + accountOrganization: "Org", + loginMethod: "OAuth") + let cached = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: reset, resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: identity) + let extra = NamedRateWindow( + id: "overflow", + title: "Overflow", + window: RateWindow( + usedPercent: 12, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 2)) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 66, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 7), + secondary: nil, + extraRateWindows: [extra], + cursorRequests: CursorRequestUsage(used: 10, limit: 50), + updatedAt: now, + identity: identity) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertEqual(result.primary?.resetsAt, reset) + XCTAssertEqual(result.primary?.usedPercent, 66) + XCTAssertEqual(result.primary?.nextRegenPercent, 7) + XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") + XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) + XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") + } + + func test_snapshotBackfillSkipsDifferentAccounts() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: nil)) + let fresh = UsageSnapshot( + primary: RateWindow(usedPercent: 66, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "new@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertNil(result.primary?.resetsAt) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 4948cb31b..39f50263f 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -41,6 +41,11 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.extraUsage, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .claude, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .claude, snapshot: nil) == false) + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary) #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary) @@ -70,6 +75,19 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) } + @Test + func `menu bar metric preference restricts text only balance providers to automatic`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric") + + for provider in [UsageProvider.deepseek, .mistral, .kimik2] { + settings.setMenuBarMetricPreference(.primary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + } + } + @Test func `minimax auth mode uses stored values`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax") diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 0b18ad89a..36079a784 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -35,6 +35,21 @@ struct SettingsStoreCoverageTests { #expect(enabled.contains(.codex)) } + @Test + func `disabling selected provider clears menu selection`() throws { + let settings = Self.makeSettingsStore() + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + settings.selectedMenuProvider = .claude + + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + + #expect(settings.selectedMenuProvider == nil) + #expect(settings.enabledProvidersOrdered(metadataByProvider: metadata) == [.codex]) + } + @Test func `menu bar metric preferences and display modes`() { let settings = Self.makeSettingsStore() @@ -59,6 +74,39 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `multi account menu layout persists and bridges legacy show all token accounts`() throws { + let suite = "SettingsStoreCoverageTests-multi-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.multiAccountMenuLayout == .segmented) + + initial.multiAccountMenuLayout = .stacked + #expect(defaults.string(forKey: "multiAccountMenuLayout") == MultiAccountMenuLayout.stacked.rawValue) + #expect(initial.showAllTokenAccountsInMenu) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.multiAccountMenuLayout == .stacked) + reloaded.showAllTokenAccountsInMenu = false + #expect(reloaded.multiAccountMenuLayout == .segmented) + } + + @Test + func `legacy show all token accounts migrates to stacked layout`() throws { + let suite = "SettingsStoreCoverageTests-legacy-token-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "showAllTokenAccountsInMenu") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.multiAccountMenuLayout == .stacked) + } + @Test func `token account mutations apply side effects`() { let settings = Self.makeSettingsStore() @@ -81,6 +129,86 @@ struct SettingsStoreCoverageTests { settings.reloadTokenAccounts() } + @Test + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `copilot token accounts clear legacy api key fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotAPIToken = "legacy-token" + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "token-1") + + settings.copilotAPIToken = "legacy-token" + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.removeTokenAccount(provider: .copilot, accountID: account.id) + + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) + } + + @Test + func `copilot enterprise host persists in provider config`() throws { + let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + first.copilotEnterpriseHost = "https://octocorp.ghe.com/login" + #expect(first.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + #expect(first.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == "octocorp.ghe.com") + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + + second.copilotEnterpriseHost = "github.com" + #expect(second.copilotEnterpriseHost == "github.com") + #expect(second.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == nil) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + @Test func `claude snapshot uses OAuth routing for OAuth token accounts`() { let settings = Self.makeSettingsStore() @@ -306,6 +434,57 @@ struct SettingsStoreCoverageTests { #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) } + @Test + func `upsert antigravity oauth account adds and updates active token account`() throws { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let updated = AntigravityOAuthCredentials( + accessToken: "updated-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(updated) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 1) + let account = try #require(accounts.first) + #expect(account.label == "user@example.com") + #expect(account.externalIdentifier == "user@example.com") + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == account.id) + + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token)) + #expect(decoded.accessToken == "updated-access") + } + + @Test + func `upsert antigravity oauth account does not merge missing email accounts by fallback label`() { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: nil) + let second = AntigravityOAuthCredentials( + accessToken: "second-access", + refreshToken: "second-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: nil) + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(second) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 2) + #expect(accounts.map(\.label) == ["Google Account 1", "Google Account 2"]) + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) + } + 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 9493cdb45..4182f310d 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -111,6 +111,58 @@ struct SettingsStoreTests { #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) } + @Test + func `provider storage setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-storage" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerStorageFootprintsEnabled == false) + #expect(defaultsA.bool(forKey: "providerStorageFootprintsEnabled") == false) + storeA.providerStorageFootprintsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerStorageFootprintsEnabled == true) + } + + @Test + func `provider changelog links setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-changelog-links" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerChangelogLinksEnabled == false) + #expect(defaultsA.bool(forKey: "providerChangelogLinksEnabled") == false) + storeA.providerChangelogLinksEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerChangelogLinksEnabled == true) + } + @Test func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" @@ -491,6 +543,144 @@ struct SettingsStoreTests { #expect(defaults.bool(forKey: key) == true) } + @Test + func `defaults quota warnings to disabled with global thresholds and sound`() throws { + let suite = "SettingsStoreTests-quota-warning-defaults" + 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()) + + #expect(store.quotaWarningNotificationsEnabled == false) + #expect(store.quotaWarningThresholds == [50, 20]) + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == true) + #expect(store.quotaWarningSoundEnabled == true) + #expect(store.quotaWarningMarkersVisible == true) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) + #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) + #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + #expect(defaults.object(forKey: "quotaWarningMarkersVisible") as? Bool == true) + } + + @Test + func `global quota warning windows persist independently`() throws { + let suite = "SettingsStoreTests-quota-warning-window-enabled" + 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()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == false) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == false) + } + + @Test + func `sanitizes invalid quota warning thresholds from defaults`() throws { + let suite = "SettingsStoreTests-quota-warning-sanitize" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set([120, 20, 20, -5, 50], forKey: "quotaWarningThresholds") + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningThresholds == [99, 50, 20, 0]) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [99, 50, 20, 0]) + } + + @Test + func `quota warning threshold pair resolves blanks and clamps bounds`() { + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: nil) == [50, 20]) + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: 10) == [50, 10]) + #expect(QuotaWarningThresholds.resolved(upper: 10, lower: nil) == [10, 0]) + #expect(QuotaWarningThresholds.resolved(upper: 120, lower: -5) == [99, 0]) + } + + @Test + func `provider quota warning override resolves before global thresholds`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-override" + 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()) + store.quotaWarningThresholds = [50, 20] + + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [50, 20]) + + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: nil) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + } + + @Test + func `global quota warning thresholds resolve independently by window`() throws { + let suite = "SettingsStoreTests-quota-warning-window-thresholds" + 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()) + + store.setQuotaWarningThresholds(.session, thresholds: [25]) + store.setQuotaWarningThresholds(.weekly, thresholds: [75, 10]) + + #expect(store.quotaWarningThresholds(.session) == [25]) + #expect(store.quotaWarningThresholds(.weekly) == [75, 10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [25]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [75, 10]) + } + + @Test + func `provider quota warning windows override global enablement independently`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-window-override" + 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()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: true) + store.setQuotaWarningWindowEnabled(provider: .codex, window: .session, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == true) + #expect(store.quotaWarningEnabled(provider: .codex, window: .session) == false) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .weekly) == true) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .session) == true) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: nil) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + } + @Test func `defaults claude usage source to auto`() throws { let suite = "SettingsStoreTests-claude-source" @@ -720,6 +910,29 @@ struct SettingsStoreTests { #expect(store.codexCookieSource == .auto) } + @Test + func `imports legacy open AI web access defaults key`() throws { + let suite = "SettingsStoreTests-openai-web-legacy-key" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "openAIWebAccess") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + } + @Test func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws { let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" @@ -821,6 +1034,40 @@ struct SettingsStoreTests { #expect(didChange.get() == true) } + @Test + func `menu observation token updates on per-window quota threshold changes`() async throws { + let suite = "SettingsStoreTests-observation-quota-threshold-windows" + 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()) + + func expectObservation( + for window: QuotaWarningWindow, + thresholds: [Int]) async + { + let didChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.setQuotaWarningThresholds(window, thresholds: thresholds) + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + await expectObservation(for: .session, thresholds: [70, 30]) + await expectObservation(for: .weekly, thresholds: [80, 40]) + } + @Test func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" @@ -911,35 +1158,9 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeA.orderedProviders() == [ - .gemini, - .codex, - .claude, - .cursor, - .opencode, - .opencodego, - .alibaba, - .factory, - .antigravity, - .copilot, - .zai, - .minimax, - .kimi, - .kilo, - .kiro, - .vertexai, - .augment, - .jetbrains, - .kimik2, - .amp, - .ollama, - .synthetic, - .warp, - .openrouter, - .perplexity, - .abacus, - .mistral, - ]) + let legacyOrder: [UsageProvider] = [.gemini, .codex] + let appendedProviders = UsageProvider.allCases.filter { !legacyOrder.contains($0) } + #expect(storeA.orderedProviders() == legacyOrder + appendedProviders) // Move one provider; ensure it's persisted across instances. let antigravityIndex = try #require(storeA.orderedProviders().firstIndex(of: .antigravity)) diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index ef85a9715..fc064a9d3 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -134,4 +134,53 @@ struct StatusItemAnimationSignatureTests { #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=cursor") == true) } + + @Test + func `split provider icon skips unchanged render signature`() throws { + let suite = "StatusItemAnimationSignatureTests-split-provider-signature" + 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 = false + settings.menuBarShowsBrandIconWithPercent = false + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + #expect(controller.applyIcon(for: .codex, phase: nil) == true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + } } diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index dc098aead..ba9bc457d 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -3,8 +3,9 @@ import CodexBarCore import Testing @testable import CodexBar -@Suite(.serialized) @MainActor +@Suite(.serialized) +// swiftlint:disable:next type_body_length struct StatusItemAnimationTests { private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat { var maxAlpha: CGFloat = 0 @@ -20,11 +21,9 @@ struct StatusItemAnimationTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test @@ -59,6 +58,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -111,6 +111,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Enter loading state: no data, no stale error. store._setSnapshotForTesting(nil, provider: .codex) @@ -165,6 +166,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). let snapshot = UsageSnapshot( @@ -218,6 +220,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Bonus exists but is unused: used=0% (remaining=100%). let snapshot = UsageSnapshot( @@ -245,6 +248,138 @@ struct StatusItemAnimationTests { #expect(alpha < 0.6) } + @Test + func `open router without key limit uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-limit-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + 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 snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(controller.statusItems[.openrouter]?.button?.title.isEmpty == true) + #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + + // With no key limit, the primary bar has no fill — just the dim track. + // A brand logo would be fully opaque here; the track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + + @Test + func `open router key data not fetched still uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-fetch-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + 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 snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .unavailable) + + // Even with no key data, OpenRouter still renders a meter rather than the brand logo. + // A brand logo would be fully opaque here; the unfilled track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + @Test func `menu bar percent uses configured metric`() { let settings = SettingsStore( @@ -270,6 +405,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -348,6 +484,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift new file mode 100644 index 000000000..1f88a7230 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -0,0 +1,443 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemBalanceDisplayTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `menu bar display text uses open router balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-balance", + provider: .openrouter) + settings.setMenuBarMetricPreference(.automatic, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar display text respects open router primary metric preference`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-primary-metric", + provider: .openrouter) + settings.setMenuBarMetricPreference(.primary, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "25%") + } + + @Test + func `menu bar display text uses deepseek balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-deepseek-balance", + provider: .deepseek) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .deepseek) + store._setErrorForTesting(nil, provider: .deepseek) + + let displayText = controller.menuBarDisplayText(for: .deepseek, snapshot: snapshot) + + #expect(displayText == "$9.32") + } + + @Test + func `menu bar display text uses moonshot balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-moonshot-balance", + provider: .moonshot) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: $49.58 · $0.42 in deficit")) + + store._setSnapshotForTesting(snapshot, provider: .moonshot) + store._setErrorForTesting(nil, provider: .moonshot) + + let displayText = controller.menuBarDisplayText(for: .moonshot, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(displayText == "$49.58") + } + + @Test + func `menu bar display text uses mistral current month api spend`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-spend", + provider: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "€1.2345") + } + + @Test + func `menu bar display text uses kimi k2 api key credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kimik2-credits", + provider: .kimik2) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = KimiK2UsageSummary( + consumed: 75, + remaining: 1234.5, + averageTokens: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kimik2) + store._setErrorForTesting(nil, provider: .kimik2) + + let displayText = controller.menuBarDisplayText(for: .kimik2, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "Credits: 1234.5 left") + #expect(displayText == "1234.5") + } + + @Test + func `kiro menu bar automatic uses credits left`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-automatic", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar credits and percent combines values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .creditsAndPercent + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83 · 0%") + } + + @Test + func `kiro menu bar hidden suppresses text value`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-hidden", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .hidden + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == nil) + } + + @Test + func `kiro menu bar used and total formats credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-used-total", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .usedAndTotal + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0.17 / 50") + } + + @Test + func `kiro menu bar overage credits mode shows overage credits when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-credits", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 over") + } + + @Test + func `kiro menu bar overage cost mode shows cost when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-cost", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "$1.61 over") + } + + @Test + func `kiro menu bar overage credits and cost mode shows both when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 · $1.61") + } + + @Test + func `kiro menu bar overage mode keeps credits left before exhaustion`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-not-exhausted", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar overage mode ignores disabled overage values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-disabled", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot(overagesStatus: "Disabled") + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0") + } + + @Test + func `kiro managed plan display falls back to percent`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-managed", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + settings.usageBarsShowUsed = false + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = KiroUsageSnapshot( + planName: "Q Developer Pro", + creditsUsed: 0, + creditsTotal: 0, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "100%") + } + + @Test + func `mistral primary window is nil even when billing end date is set`() { + let endDate = Date(timeIntervalSinceNow: 3600) + let snapshot = MistralUsageSnapshot( + totalCost: 0.5, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCachedTokens: 0, + modelCount: 1, + startDate: nil, + endDate: endDate, + updatedAt: Date()).toUsageSnapshot() + + // Mistral doesn't expose a reset time — primary is always nil. + #expect(snapshot.primary == nil) + } + + @Test + func `button title spacing only applies when image is present`() { + #expect(StatusItemController.buttonTitle("42%", hasImage: true) == " 42%") + #expect(StatusItemController.buttonTitle("42%", hasImage: false) == "42%") + #expect(StatusItemController.buttonTitle(nil, hasImage: true).isEmpty) + #expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty) + } + + private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) + } + return settings + } + + private func makeStoreAndController(settings: SettingsStore) -> (UsageStore, StatusItemController) { + 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()) + return (store, controller) + } + + private static func openRouterSnapshot() -> UsageSnapshot { + OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 37.66, + balance: 12.34, + usedPercent: 75.32, + keyLimit: 20, + keyUsage: 5, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + } + + private static func kiroSnapshot() -> UsageSnapshot { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Disabled", + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } + + private static func exhaustedKiroSnapshot(overagesStatus: String = "Enabled billed at $0.04 per request") + -> UsageSnapshot + { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 50, + creditsTotal: 50, + creditsPercent: 100, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + overagesStatus: overagesStatus, + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + manageURL: "https://app.kiro.dev/account/usage", + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index f6e2d2870..9e3f2d3de 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -5,6 +5,26 @@ import Testing @testable import CodexBar struct StatusItemControllerMenuTests { + @MainActor + private final class RecordingUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + var checkForUpdatesCount = 0 + var installUpdateCount = 0 + + func checkForUpdates(_ sender: Any?) { + _ = sender + self.checkForUpdatesCount += 1 + } + + func installUpdate() { + self.installUpdateCount += 1 + } + } + private func makeSnapshot( primary: RateWindow?, secondary: RateWindow?, @@ -96,61 +116,6 @@ struct StatusItemControllerMenuTests { #expect(percent == 76) } - @Test - func `open router brand fallback enabled when no key limit configured`() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: true, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) - } - - @Test - func `open router brand fallback disabled when key quota fetch unavailable`() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: false, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - } - - @Test - func `open router brand fallback disabled when key quota available`() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyLimit: 20, - keyUsage: 2, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(snapshot.primary?.usedPercent == 10) - } - @Test @MainActor func `menu card width stays at base width when menu accessories are present`() { @@ -165,4 +130,35 @@ struct StatusItemControllerMenuTests { submenuMenu.addItem(parentItem) #expect(ceil(submenuMenu.size.width) < 310) } + + @Test + @MainActor + func `update menu action installs prepared update instead of checking again`() throws { + let suite = "StatusItemControllerMenuTests-\(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.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let updater = RecordingUpdater() + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + controller.installUpdate() + + #expect(updater.installUpdateCount == 1) + #expect(updater.checkForUpdatesCount == 0) + } } diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift new file mode 100644 index 000000000..bbfb82307 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -0,0 +1,127 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerSplitLifecycleTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerSplitLifecycleTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func containsHostingView(_ view: NSView) -> Bool { + if String(describing: type(of: view)).contains("NSHostingView") { + return true + } + return view.subviews.contains { self.containsHostingView($0) } + } + + private func makeSplitController() throws -> (SettingsStore, StatusItemController) { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) + } + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + 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()) + return (settings, controller) + } + + @Test + func `merged mode removes split provider status items`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.statusItems[.codex] != nil) + #expect(controller.statusItems[.claude] != nil) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + #expect(controller.statusItem.isVisible == true) + #expect(controller.statusItems.isEmpty) + } + + @Test + func `menu bar icons stay appkit hosted`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + #expect(codexButton.image != nil) + #expect(!self.containsHostingView(codexButton)) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + #expect(!self.containsHostingView(mergedButton)) + } + + @Test + func `visibility recovery recreates split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + controller.recreateStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + #expect(newCodexItem !== oldCodexItem) + } + + @Test + func `visibility recovery renders replacement merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let renderedSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + controller.lastAppliedMergedIconRenderSignature = renderedSignature + controller.recreateStatusItemsForVisibilityRecovery() + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + } +} diff --git a/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift new file mode 100644 index 000000000..58d126e37 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBar + +struct StatusItemPurchaseURLTests { + @Test + @MainActor + func `purchase URL accepts ChatGPT hosts`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/settings/billing") + == "https://chatgpt.com/settings/billing") + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com/usage/credits?token=secret#fragment") + == "https://chatgpt.com/usage/credits") + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://team.chatgpt.com/settings/billing") + == "https://team.chatgpt.com/settings/billing") + } + + @Test + @MainActor + func `purchase URL rejects lookalike hosts`() { + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com.evil.example/settings/billing") == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://evil-chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://notchatgpt.com/settings/billing") + == nil) + } + + @Test + @MainActor + func `purchase URL rejects non HTTPS and unrelated paths`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("http://chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/accounts") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/settings-token") + == nil) + #expect(StatusItemController.sanitizedCreditsPurchaseURL("not a url") == nil) + } +} diff --git a/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift new file mode 100644 index 000000000..abf10e6d4 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift @@ -0,0 +1,98 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemQuotaWarningFlashTests { + private func makeStatusBarForTesting() -> NSStatusBar { + NSStatusBar.system + } + + @Test + func `quota warning flash state lasts for configured duration`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-duration"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + 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 now = Date() + controller.startQuotaWarningFlash(provider: .codex, postedAt: now) + + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(59)) == true) + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(61)) == false) + } + + @Test + func `quota warning flash image draws non template red overlay`() throws { + let size = NSSize(width: 16, height: 16) + let base = NSImage(size: size) + base.lockFocus() + NSColor.black.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + base.unlockFocus() + base.isTemplate = true + + let output = StatusItemController.quotaWarningFlashImage(base: base) + let outputData = try #require(output.tiffRepresentation) + let outputRep = try #require(NSBitmapImageRep(data: outputData)) + let center = try #require(outputRep.colorAt(x: 8, y: 8)) + + #expect(output.isTemplate == false) + #expect(center.redComponent > center.blueComponent) + } + + @Test + func `merged icon render signature includes quota warning flash for selected provider`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-merged"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.startQuotaWarningFlash(provider: .codex) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift new file mode 100644 index 000000000..cf65a2102 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -0,0 +1,279 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusMenuCodexSwitcherPresentationTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexSwitcherPresentationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeManagedAccountStoreURL(accounts: [ManagedCodexAccount]) throws -> URL { + let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: accounts)) + return storeURL + } + + 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 representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + + @Test + func `codex account ordering keeps workspace groups contiguous`() { + let teamActive = CodexVisibleAccount( + id: "team-a-active", + email: "active@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamHighQuota = CodexVisibleAccount( + id: "team-b-high-quota", + email: "high@example.com", + workspaceLabel: "Team B", + workspaceAccountID: "team-b", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamSibling = CodexVisibleAccount( + id: "team-a-sibling", + email: "sibling@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let accounts = [teamActive, teamHighQuota, teamSibling] + let snapshots = [ + CodexAccountUsageSnapshot( + account: teamActive, + snapshot: self.snapshot(email: teamActive.email, percent: 95), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamHighQuota, + snapshot: self.snapshot(email: teamHighQuota.email, percent: 10), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamSibling, + snapshot: self.snapshot(email: teamSibling.email, percent: 20), + error: nil, + sourceLabel: "test"), + ] + + let ordered = CodexAccountPresentationOrdering.orderedAccounts( + accounts, + snapshots: snapshots, + activeVisibleAccountID: teamActive.id) + + #expect(ordered.map(\.id) == ["team-a-active", "team-a-sibling", "team-b-high-quota"]) + #expect(ordered.codexWorkspaceSections().map(\.title) == ["Team A", "Team B"]) + #expect(ordered.codexWorkspaceSections().first?.accounts.map(\.id) == ["team-a-active", "team-a-sibling"]) + } + + @Test + func `codex stacked menu orders by quota and groups workspaces`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let lowID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let highID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let low = ManagedCodexAccount( + id: lowID, + email: "low@example.com", + workspaceLabel: "Team Low", + workspaceAccountID: "team-low", + managedHomePath: "/tmp/low-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let high = ManagedCodexAccount( + id: highID, + email: "high@example.com", + workspaceLabel: "Team High", + workspaceAccountID: "team-high", + managedHomePath: "/tmp/high-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [low, high]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "active@example.com", + workspaceLabel: "Personal", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.codexAccountSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + let usedPercent = switch account.email { + case "high@example.com": + 10.0 + case "low@example.com": + 80.0 + default: + 95.0 + } + return CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: usedPercent), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let display = try #require(controller.codexAccountMenuDisplay(for: .codex)) + #expect(display.accounts.map(\.email) == ["active@example.com", "high@example.com", "low@example.com"]) + #expect(display.workspaceSections.map(\.title) == ["Personal", "Team High", "Team Low"]) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + #expect(self.representedIDs(in: menu).count(where: { $0.hasPrefix("codexWorkspace-") }) == 3) + } + + @Test + func `codex stacked menu surfaces account health labels`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.email == "managed@example.com" }) + + #expect(CodexAccountHealth.status(for: visibleAccount, error: "401 Unauthorized") + .label == "Needs re-auth") + } + + @Test + func `codex account snapshot store hydrates current visible accounts`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let account = CodexVisibleAccount( + id: "active@example.com", + email: "active@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: 17), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [account]) + + #expect(hydrated.map(\.id) == [account.id]) + #expect(hydrated.first?.snapshot?.primary?.usedPercent == 17) + #expect(hydrated.first?.account.email == account.email) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index eae588822..a312dceaf 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -8,7 +9,7 @@ import Testing struct StatusMenuCodexSwitcherTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } private func makeSettings() -> SettingsStore { @@ -23,6 +24,10 @@ struct StatusMenuCodexSwitcherTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + private func enableOnlyCodex(_ settings: SettingsStore) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { @@ -47,6 +52,30 @@ struct StatusMenuCodexSwitcherTests { } } + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + private func selectCodexVisibleAccountForStatusMenu( id: String, settings: SettingsStore, @@ -163,6 +192,283 @@ struct StatusMenuCodexSwitcherTests { #expect(self.actionLabels(in: descriptor).contains("Add Account...")) } + @Test + func `codex segmented multi account layout shows account switcher`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + 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 = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first != nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard"]) + } + + @Test + func `merged codex menu smart refresh keeps account switcher visible`() throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + 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 || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + 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 = controller.makeMenu() + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + settings._test_liveSystemCodexAccount = nil + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuDidClose(menu) + controller.menuContentVersion &+= 1 + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 0) + } + + @Test + func `codex menu can select preserved switcher row during transient account projection`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .managedAccount(id: managedAccountID) + + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.selectionSource == .liveSystem }) + settings._test_liveSystemCodexAccount = nil + + #expect(settings.codexVisibleAccountProjection.visibleAccounts.map(\.email) == ["managed@example.com"]) + settings.selectDisplayedCodexVisibleAccount(liveAccount) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + func `codex stacked multi account layout shows account cards`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + store.codexAccountSnapshots = projection.visibleAccounts.enumerated().map { index, account in + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex stacked multi account layout shows account cards before per account snapshots load`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com"), 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(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + @Test func `codex switcher suppresses personal labels while preserving team workspace tooltips`() { let accounts = [ @@ -210,6 +516,122 @@ struct StatusMenuCodexSwitcherTests { #expect(accounts[0].menuDisplayName == "pl.fr@yandex.com") } + @Test + func `codex switcher reports fixed menu width for long account labels`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "managed-account-with-a-very-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "steipete-with-a-very-long-label@gmail.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 310, + onSelect: { _ in }) + + #expect(view.frame.width == 310) + #expect(view.intrinsicContentSize.width == 310) + #expect(view.fittingSize.width == 310) + } + + @Test + func `codex switcher middle truncates long account emails`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "local-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "second-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + let titles = view._test_buttonTitles() + + #expect(titles.count == 2) + #expect(titles[0].hasPrefix("local")) + #expect(titles[0].contains("…")) + #expect(titles[0].hasSuffix(".com")) + #expect(titles[1].hasPrefix("second")) + #expect(titles[1].contains("…")) + #expect(titles[1].hasSuffix(".com")) + } + + @Test + func `codex account switcher passes the selected displayed account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + view._test_selectAccount(id: "managed@example.com") + + #expect(selectedAccount == accounts[1]) + } + @Test func `codex menu switcher selection activates the visible managed account`() throws { self.disableMenuCardsForTesting() @@ -281,6 +703,9 @@ struct StatusMenuCodexSwitcherTests { let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), @@ -347,7 +772,8 @@ struct StatusMenuCodexSwitcherTests { store: InMemoryManagedCodexAccountStoreForStatusMenuTests(), homeFactory: TestManagedCodexHomeFactoryForStatusMenuTests(root: root), loginRunner: runner, - identityReader: StubManagedCodexIdentityReaderForStatusMenuTests(email: "managed@example.com")) + identityReader: StubManagedCodexIdentityReaderForStatusMenuTests(email: "managed@example.com"), + workspaceResolver: StubManagedCodexWorkspaceResolverForStatusMenuTests()) let coordinator = ManagedCodexAccountCoordinator(service: service) let authTask = Task { try await coordinator.authenticateManagedAccount() } await runner.waitUntilStarted() @@ -446,7 +872,147 @@ struct StatusMenuCodexSwitcherTests { } } +@MainActor extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switch defers open menu rebuild until after switcher action`() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + 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 || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com", percent: 11), provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first) + let managedVisibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + switcher._test_selectAccount(id: managedVisibleAccount.id) + + #expect(rebuildCount == 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + #expect(rebuildCount == 1) + + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.snapshot(email: "managed@example.com", percent: 17))) + } + + @Test + func `codex stacked refresh discards selected outcome when visible selection changes mid flight`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "managed@example.com", percent: 77), provider: .codex) + + let projection = settings.codexVisibleAccountProjection + let originalVisibleAccountID = projection.activeVisibleAccountID + let originalSelectionSource = originalVisibleAccountID.flatMap { + projection.source(forVisibleAccountID: $0) + } + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource)) + + #expect(settings.selectCodexVisibleAccount(id: "managed@example.com")) + + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource) == false) + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "managed@example.com") + #expect(store.snapshots[.codex]?.primary?.usedPercent == 77) + } + private static func writeCodexAuthFile( homeURL: URL, email: String, @@ -518,23 +1084,26 @@ private struct StatusMenuTestCodexFetchStrategy: ProviderFetchStrategy { private actor BlockingStatusMenuCodexFetchStrategy { private var waiters: [CheckedContinuation, Never>] = [] - private var startedWaiters: [CheckedContinuation] = [] - private var didStart = false + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var startCount = 0 func awaitResult() async throws -> UsageSnapshot { - self.didStart = true - self.startedWaiters.forEach { $0.resume() } - self.startedWaiters.removeAll() let result = await withCheckedContinuation { continuation in self.waiters.append(continuation) + self.startCount += 1 + self.resumeStartedWaitersIfReady() } return try result.get() } func waitUntilStarted() async { - if self.didStart { return } + await self.waitForStartCount(1) + } + + func waitForStartCount(_ count: Int) async { + if self.startCount >= count { return } await withCheckedContinuation { continuation in - self.startedWaiters.append(continuation) + self.startedWaiters.append((count, continuation)) } } @@ -542,6 +1111,12 @@ private actor BlockingStatusMenuCodexFetchStrategy { self.waiters.forEach { $0.resume(returning: result) } self.waiters.removeAll() } + + private func resumeStartedWaitersIfReady() { + let readyWaiters = self.startedWaiters.filter { self.startCount >= $0.count } + self.startedWaiters.removeAll { self.startCount >= $0.count } + readyWaiters.forEach { $0.continuation.resume() } + } } private actor BlockingManagedCodexLoginRunnerForStatusMenuTests: ManagedCodexLoginRunning { @@ -550,11 +1125,11 @@ private actor BlockingManagedCodexLoginRunnerForStatusMenuTests: ManagedCodexLog private var didStart = false func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { - self.didStart = true - self.startedWaiters.forEach { $0.resume() } - self.startedWaiters.removeAll() - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in self.waiters.append(continuation) + self.didStart = true + self.startedWaiters.forEach { $0.resume() } + self.startedWaiters.removeAll() } } @@ -589,6 +1164,15 @@ private final class InMemoryManagedCodexAccountStoreForStatusMenuTests: ManagedC } } +private struct StubManagedCodexWorkspaceResolverForStatusMenuTests: ManagedCodexWorkspaceResolving { + func resolveWorkspaceIdentity( + homePath _: String, + providerAccountID _: String) async -> CodexOpenAIWorkspaceIdentity? + { + nil + } +} + private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomeProducing { let root: URL diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift new file mode 100644 index 000000000..b1f73389a --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuCostMenuCardTests { + @Test + func `cost menu fallback keeps visible details in attributed title`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $74.83 - 87M tokens", + monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + let visibleLines = StatusItemController.costMenuVisibleDetailLines(tokenUsage: tokenUsage) + #expect(visibleLines == [ + "Today: $74.83 - 87M tokens", + "Last 30 days: $4,279.64 - 5.7B tokens", + "Cost refresh failed.", + ]) + + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle(visibleDetailLines: visibleLines) + #expect(fallbackTitle.string.contains("Cost")) + #expect(fallbackTitle.string.contains("Today: $74.83 - 87M tokens")) + #expect(fallbackTitle.string.contains("Last 30 days: $4,279.64 - 5.7B tokens")) + #expect(fallbackTitle.string.contains("Cost refresh failed.")) + } + + @Test + func `cost menu tooltip preserves hint and error details`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $1.00", + monthLine: "Last 30 days: $9.00", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + #expect(StatusItemController.costMenuTooltipLines(tokenUsage: tokenUsage) == [ + "Today: $1.00", + "Last 30 days: $9.00", + "Costs are estimated from local usage.", + "Cost refresh failed.", + ]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift new file mode 100644 index 000000000..845c2ab6c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -0,0 +1,127 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuHostedSubmenuRefreshTests { + @Test + func `open parent menu defers data rebuild until next open`() 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() + 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) + Self.seedClaudeSnapshots(in: store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let parentKey = ObjectIdentifier(menu) + controller.openMenus[parentKey] = menu + controller.menuVersions[parentKey] = controller.menuContentVersion + + let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) + #expect(costItem.view == nil) + let submenu = try #require(costItem.submenu) + let submenuAction = try #require(costItem.action) + #expect(NSStringFromSelector(submenuAction) == "submenuAction:") + #expect((costItem.target as? NSMenu) === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) + #expect(submenu.items.first?.view == nil) + + StatusItemController.setMenuRefreshEnabledForTesting(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() + #expect(controller.menuVersions[parentKey] == oldParentVersion) + + controller.menuDidClose(submenu) + #expect(controller.openMenus[submenuKey] == nil) + + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableOnlyClaude(_ settings: SettingsStore) { + 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) + } + } + + private static func seedClaudeSnapshots(in store: UsageStore) { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Team")) + store._setSnapshotForTesting(snapshot, provider: .claude) + 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: .claude) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift new file mode 100644 index 000000000..c1871ebf7 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -0,0 +1,97 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `store observation marks open menu stale without rebuilding during tracking`() 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 + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 33, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `explicit store actions refresh a visible open menu`() { + 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 + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + + controller.refreshOpenMenusAfterExplicitStoreAction() + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift new file mode 100644 index 000000000..10ec3e242 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -0,0 +1,64 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `overview rows expose provider detail submenus`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .openai + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .openai || provider == .codex + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let openAIRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-openai" + }) + #expect(openAIRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID + } == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift new file mode 100644 index 000000000..7e4f5ceac --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -0,0 +1,109 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate { + var refreshCount = 0 + var navigationDirections: [StatusItemMenuProviderNavigationDirection] = [] + + func performPersistentRefreshAction() { + self.refreshCount += 1 + } + + func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { + self.navigationDirections.append(direction) + } +} + +@MainActor +@Suite(.serialized) +struct StatusMenuPersistentRefreshTests { + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuPersistentRefreshTests-\(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()) + } + + @Test + func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + 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(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(refreshItem.action == nil) + #expect(refreshItem.target == nil) + #expect(refreshItem.view != nil) + #expect(refreshItem.keyEquivalent == "r") + #expect(refreshItem.keyEquivalentModifierMask == [.command]) + } + + @Test + func `refresh menu item view keeps fixed metrics while highlighted`() { + let view = PersistentMenuActionItemView( + title: "Refresh", + systemImageName: "arrow.clockwise", + shortcutText: "⌘R", + width: 320, + onClick: {}) + + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + + view.setFrameSize(NSSize(width: 360, height: 44)) + #expect(view.frame.width == 360) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + + view.setHighlighted(true) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + + view.setHighlighted(false) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + } + + @Test + func `status item menu intercepts refresh shortcut without native item selection`() throws { + let menu = StatusItemMenu() + let recorder = RefreshShortcutRecorder() + menu.persistentActionDelegate = recorder + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "r", + charactersIgnoringModifiers: "r", + isARepeat: false, + keyCode: 15)) + + #expect(menu.performKeyEquivalent(with: event) == true) + #expect(recorder.refreshCount == 1) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift new file mode 100644 index 000000000..03f974ee8 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -0,0 +1,212 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherClickTests { + private func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherClickTests-\(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()) + } + + @Test + func `merged switcher routes runtime clicks after overview round-trip`() throws { + // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a + // sub-provider tab dropped through NSButton's tracking and never updated state. + 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 = .claude + 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 + 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 = controller.makeMenu() + controller.menuWillOpen(menu) + + // Step 1: provider → Overview via the runtime click path. + let switcher1 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher1._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 2: Overview → provider via the runtime click path. Tag 2 is the second provider + // (claude) since tag 0 is Overview and tag 1 is the first provider. + let switcher2 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher2._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + // Step 3: provider → Overview again. + let switcher3 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher3._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 4: Overview → other provider. This is the click that previously got dropped. + let switcher4 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher4._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + } + + @Test + func `merged switcher handles left and right arrow keyboard navigation`() 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 + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: [.codex, .claude]) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: [.codex, .claude]) + + 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 = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + } + + @Test + func `switcher hover styling keeps layout stable`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + + let initialSize = view.intrinsicContentSize + let initialFrames = view._test_buttonFrames() + + view._test_setHoveredButtonTag(3) + view._test_setHoveredButtonTag(6) + view._test_setHoveredButtonTag(nil as Int?) + + #expect(view.intrinsicContentSize == initialSize) + #expect(view._test_buttonFrames() == initialFrames) + } + + @Test + func `switcher quota indicator preserves remaining percentage`() throws { + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 5 + case .grok: + 95 + default: + nil + } + }, + onSelect: { _ in }) + + let fillWidths = view._test_quotaIndicatorFillWidths() + #expect(fillWidths.count == 2) + let lowRemainingWidth = try #require(fillWidths.first) + let highRemainingWidth = try #require(fillWidths.last) + #expect(lowRemainingWidth < highRemainingWidth) + } + + private static func arrowKeyEvent(keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift new file mode 100644 index 000000000..4d71f58e1 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -0,0 +1,103 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherRefreshTests { + @Test + func `merged provider switch rebuilds stale width switcher rows`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.isRefreshing = true + defer { store.isRefreshing = false } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.openMenus[ObjectIdentifier(menu)] === menu) + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + initialSwitcher.frame.size.width = 250 + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let nextProviderButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: nextProviderButton.tag) == true) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(updatedSwitcher.frame.width == 310) + #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableCodexAndClaude(_ settings: SettingsStore) { + 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) + } + } + + 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/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 0d0ec05d2..e71d180a0 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,21 +4,20 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct StatusMenuTests { - private func disableMenuCardsForTesting() { + func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } - private func makeSettings() -> SettingsStore { + func makeSettings() -> SettingsStore { let suite = "StatusMenuTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -30,7 +29,7 @@ struct StatusMenuTests { syntheticTokenStore: NoopSyntheticTokenStore()) } - private func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { + func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { let now = Date() let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -86,6 +85,7 @@ struct StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false + settings.providerDetectionCompleted = true settings.alibabaCodingPlanAPIRegion = .chinaMainland let fetcher = UsageFetcher() @@ -101,6 +101,69 @@ struct StatusMenuTests { #expect(controller.dashboardURL(for: .alibaba) == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL) } + @Test + func `opencode go dashboard action follows configured workspace`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.opencodegoWorkspaceID = "https://opencode.ai/workspace/wrk_abc123/go" + + 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()) + + #expect(controller.dashboardURL(for: .opencodego)? + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + } + + @Test + func `claude subscription dashboard action opens usage page`() { + for plan in ["Claude Pro", "Claude Team"] { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: plan)), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .claude)?.absoluteString == "https://claude.ai/settings/usage") + } + } + @Test func `remembers provider when menu opens`() { self.disableMenuCardsForTesting() @@ -129,6 +192,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let claudeMenu = controller.makeMenu() controller.menuWillOpen(claudeMenu) @@ -155,16 +219,12 @@ struct StatusMenuTests { settings.selectedMenuProvider = nil let registry = ProviderRegistry.shared - var enabledProviders: [UsageProvider] = [] + let selectedProviders: Set = [.codex, .claude] for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = enabledProviders.count < 2 + let shouldEnable = selectedProviders.contains(provider) settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - if shouldEnable { - enabledProviders.append(provider) - } } - #expect(enabledProviders.count == 2) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -175,6 +235,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -187,7 +248,95 @@ struct StatusMenuTests { } @Test - func `merged menu refresh uses resolved enabled provider when persisted selection is disabled`() { + func `shortcut closes tracked menu instead of queueing another open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = 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 menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + #expect(controller.openMenus[key] != nil) + + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == true) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == false) + } + + @Test + func `open menu defers store data refresh until next open`() 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()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + let openedVersion = controller.menuVersions[key] + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<50 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + let staleVersion = controller.menuContentVersion + controller.refreshOpenMenusIfNeeded() + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == staleVersion) + } + + @Test + func `merged menu refresh uses resolved enabled provider when selection is cleared`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -226,6 +375,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -245,7 +395,7 @@ struct StatusMenuTests { controller.menuWillOpen(menu) #expect(controller.lastMenuProvider == expectedResolved) - #expect(settings.selectedMenuProvider == .codex) + #expect(settings.selectedMenuProvider == nil) #expect(hasOpenAIWebSubmenus(menu) == false) controller.menuContentVersion &+= 1 @@ -254,6 +404,75 @@ struct StatusMenuTests { #expect(hasOpenAIWebSubmenus(menu) == false) } + @Test + func `delayed menu refresh skips when refresh disabled during delay`() async { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + var delayedRefreshWakeCount = 0 + + await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + statusBar: self.makeStatusBarForTesting()) + { controller in + controller.onDelayedMenuRefreshAttemptForTesting = { + delayedRefreshWakeCount += 1 + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + StatusItemController.setMenuRefreshEnabledForTesting(false) + try? await Task.sleep(for: .milliseconds(180)) + } + + #expect(delayedRefreshWakeCount == 0) + } + + @Test + func `login state callbacks do not attach menus after release`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + 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()) + + controller.releaseStatusItemsForTesting() + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + + controller.activeLoginProvider = .codex + let loginTask = Task {} + controller.loginTask = loginTask + loginTask.cancel() + controller.loginTask = nil + controller.activeLoginProvider = nil + + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + } + @Test func `display only dashboard does not show code review in status menu card`() throws { self.disableMenuCardsForTesting() @@ -333,6 +552,9 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(initialSwitcher != nil) @@ -348,61 +570,6 @@ struct StatusMenuTests { } } - @Test - func `merged provider switch rebuilds stale width switcher rows`() { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - - 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 activeProviders: [UsageProvider] = [.codex, .claude] - _ = settings.setMergedOverviewProviderSelection( - provider: .codex, - isSelected: false, - activeProviders: activeProviders) - _ = settings.setMergedOverviewProviderSelection( - provider: .claude, - isSelected: false, - activeProviders: activeProviders) - - 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 = controller.makeMenu() - controller.menuWillOpen(menu) - - let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView - #expect(initialSwitcher != nil) - let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) - initialSwitcher?.frame.size.width = 250 - - let nextProviderButton = self.switcherButtons(in: menu).first(where: { $0.state == .off }) - #expect(nextProviderButton != nil) - nextProviderButton?.performClick(nil) - - let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView - #expect(updatedSwitcher != nil) - if let initialSwitcherID, let updatedSwitcher { - #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) - #expect(updatedSwitcher.frame.width == 310) - } - } - @Test func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() @@ -523,6 +690,9 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } let initialButtons = self.switcherButtons(in: menu) #expect(initialButtons.count == activeProviders.count) @@ -532,7 +702,8 @@ struct StatusMenuTests { isSelected: true, activeProviders: activeProviders) controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.menuDidClose(menu) + controller.menuWillOpen(menu) let updatedButtons = self.switcherButtons(in: menu) #expect(updatedButtons.count == activeProviders.count + 1) @@ -666,6 +837,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.claude]?.isVisible == true) @@ -673,7 +845,52 @@ extension StatusMenuTests { settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) } controller.handleProviderConfigChange(reason: "test") - #expect(controller.statusItems[.claude]?.isVisible == false) + #expect(controller.statusItems[.claude] == nil) + } + + @Test + func `provider config changes preserve status item instances`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: false) + + 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 codexItem = try #require(controller.statusItems[.codex]) + #expect(!controller.statusItem.autosaveName.hasPrefix("codexbar-")) + #expect(!codexItem.autosaveName.hasPrefix("codexbar-")) + + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: true) + 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) } @Test @@ -714,6 +931,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -776,10 +994,10 @@ extension StatusMenuTests { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = true - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering - StatusItemController.menuRefreshEnabled = previousMenuRefresh + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() @@ -828,6 +1046,62 @@ extension StatusMenuTests { #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5) } + @Test + func `hosted storage submenu is height capped and scroll enabled`() { + 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() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerStorageFootprintsEnabled = true + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1_756_000_000, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 1_500_000_000), + .init(path: "\(root)/file-history", totalBytes: 103_000_000), + .init(path: "\(root)/telemetry", totalBytes: 51_000_000), + .init(path: "\(root)/plugins", totalBytes: 33_000_000), + .init(path: "\(root)/history.jsonl", totalBytes: 3_800_000), + .init(path: "\(root)/shell-snapshots", totalBytes: 1_500_000), + .init(path: "\(root)/plans", totalBytes: 1_100_000), + .init(path: "\(root)/paste-cache", totalBytes: 541_000), + .init(path: "\(root)/session-env", totalBytes: 208_000), + .init(path: "\(root)/todos", totalBytes: 6700), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = NSMenu() + let didAppend = controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: 310) + + #expect(didAppend) + let item = submenu.items.first + #expect(item?.isEnabled == true) + #expect((item?.view?.frame.height ?? 0) <= 620) + } + @Test func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() @@ -884,6 +1158,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -897,6 +1172,59 @@ extension StatusMenuTests { .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) } + @Test + func `shows open AI API usage chart submenu without codex web history`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + 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) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .openai) + controller.menuWillOpen(menu) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + + #expect(usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID } == true) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardExtraUsage" } == false) + } + @Test func `shows credits before cost in codex menu card sections`() throws { self.disableMenuCardsForTesting() @@ -955,6 +1283,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -993,6 +1322,8 @@ extension StatusMenuTests { let submenu = controller.makeHostedSubviewPlaceholderMenu( chartID: StatusItemController.costHistoryChartID, provider: .codex) + #expect(submenu.autoenablesItems == false) + #expect(submenu.items.first?.isEnabled == true) controller.hydrateHostedSubviewMenuIfNeeded(submenu) #expect(submenu.items.count == 1) @@ -1020,6 +1351,7 @@ extension StatusMenuTests { #expect(submenu.items.count == 1) #expect(submenu.items.first?.title != "No data available") #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.isEnabled == true) } @Test @@ -1089,6 +1421,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -1144,6 +1477,7 @@ extension StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -1283,7 +1617,7 @@ extension StatusMenuTests { @Test func `overview rows keep menu item action in rendered mode`() throws { StatusItemController.menuCardRenderingEnabled = true - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) defer { self.disableMenuCardsForTesting() } let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift new file mode 100644 index 000000000..0ae73e8c7 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -0,0 +1,394 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +@MainActor +final class StatusMenuTokenAccountSwitcherTests: XCTestCase { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuTokenAccountSwitcherTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(.claude, settings) + } + + private func enableOnly(_ enabledProvider: UsageProvider, _ 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 == enabledProvider) + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func installBlockingClaudeProvider(on store: UsageStore, blocker: BlockingTokenAccountFetchStrategy) { + let baseSpec = store.providerSpecs[.claude]! + store.providerSpecs[.claude] = Self.makeClaudeProviderSpec(baseSpec: baseSpec) { + try await blocker.awaitResult() + } + } + + private static func makeClaudeProviderSpec( + baseSpec: ProviderSpec, + loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec + { + let baseDescriptor = baseSpec.descriptor + let strategy = StatusMenuTokenAccountFetchStrategy(loader: loader) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli) + return ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + private func snapshot(percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + } + + func test_tokenAccountMenuSelectionRefreshesProviderWhileGlobalRefreshIsActive() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let refreshTask = Task { @MainActor in + await store.refresh() + } + await blocker.waitUntilStarted(count: 1) + XCTAssertTrue(store.isRefreshing) + + let menu = controller.makeMenu() + defer { withExtendedLifetime(menu) {} } + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + await blocker.waitUntilStarted(count: 2) + XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + await refreshTask.value + let startedCallCount = await blocker.startedCallCount() + XCTAssertGreaterThanOrEqual(startedCallCount, 2) + } + + func test_multiAccountSegmentedLayoutShowsCopilotSwitcher() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + + 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 = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + _ = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard"]) + } + + func test_multiAccountStackedLayoutShowsCopilotCards() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) + } + + func test_multiAccountStackedLayoutIgnoresStaleSnapshotsAndKeepsMenuCapped() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + for index in 0..<8 { + settings.addTokenAccount(provider: .copilot, label: "Account \(index)", token: "gh_\(index)") + } + settings.setActiveTokenAccountIndex(7, for: .copilot) + let accounts = settings.tokenAccounts(for: .copilot) + let staleAccounts = (0..<2).map { index in + ProviderTokenAccount( + id: UUID(), + label: "Removed \(index)", + token: "stale_\(index)", + addedAt: TimeInterval(index), + lastUsed: nil) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let staleSnapshots = staleAccounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(70 + index)), + error: nil, + sourceLabel: "stale") + } + let currentSnapshots = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "current") + } + store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual( + self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, + ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) + } + + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.multiAccountMenuLayout = .segmented + 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 == .claude || provider == .codex) + } + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(rebuildCount, 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 1) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + } +} + +private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async throws -> UsageSnapshot + + var id: String { + "status-menu-token-account-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader() + return self.makeResult(usage: snapshot, sourceLabel: "status-menu-token-account-test") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor BlockingTokenAccountFetchStrategy { + private var waiters: [CheckedContinuation, Never>] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var resolvedResult: Result? + private var startedCount = 0 + + func awaitResult() async throws -> UsageSnapshot { + if let resolvedResult { + self.startedCount += 1 + self.resumeStartedWaiters() + return try resolvedResult.get() + } + let result = await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.startedCount += 1 + self.resumeStartedWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int) async { + if self.startedCount >= count { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append((count: count, continuation: continuation)) + } + } + + func startedCallCount() -> Int { + self.startedCount + } + + func resumeAll(with result: Result) { + self.resolvedResult = result + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } + + private func resumeStartedWaiters() { + let ready = self.startedWaiters.filter { self.startedCount >= $0.count } + self.startedWaiters.removeAll { self.startedCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } +} diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift new file mode 100644 index 000000000..ce06f27f9 --- /dev/null +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -0,0 +1,233 @@ +import CodexBarCore +import Foundation +import Testing + +struct StepFunSettingsReaderTests { + @Test + func `reads STEPFUN_TOKEN`() { + let env = ["STEPFUN_TOKEN": "some-oasis-token-value"] + #expect(StepFunSettingsReader.token(environment: env) == "some-oasis-token-value") + } + + @Test + func `reads STEPFUN_USERNAME`() { + let env = ["STEPFUN_USERNAME": "user@example.com"] + #expect(StepFunSettingsReader.username(environment: env) == "user@example.com") + } + + @Test + func `reads STEPFUN_PASSWORD`() { + let env = ["STEPFUN_PASSWORD": "secret123"] + #expect(StepFunSettingsReader.password(environment: env) == "secret123") + } + + @Test + func `trims whitespace from token`() { + let env = ["STEPFUN_TOKEN": " some-token "] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips double quotes from token`() { + let env = ["STEPFUN_TOKEN": "\"some-token\""] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips single quotes from token`() { + let env = ["STEPFUN_TOKEN": "'some-token'"] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `returns nil when no env vars present`() { + #expect(StepFunSettingsReader.token(environment: [:]) == nil) + #expect(StepFunSettingsReader.username(environment: [:]) == nil) + #expect(StepFunSettingsReader.password(environment: [:]) == nil) + } + + @Test + func `returns nil for empty values`() { + let env = ["STEPFUN_TOKEN": "", "STEPFUN_USERNAME": "", "STEPFUN_PASSWORD": ""] + #expect(StepFunSettingsReader.token(environment: env) == nil) + #expect(StepFunSettingsReader.username(environment: env) == nil) + #expect(StepFunSettingsReader.password(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only values`() { + let env = ["STEPFUN_TOKEN": " "] + #expect(StepFunSettingsReader.token(environment: env) == nil) + } +} + +struct StepFunProviderTokenResolverTests { + @Test + func `resolves token from environment`() { + let env = ["STEPFUN_TOKEN": "my-test-token"] + let resolution = ProviderTokenResolver.stepfunResolution(environment: env) + #expect(resolution?.token == "my-test-token") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when token absent`() { + let resolution = ProviderTokenResolver.stepfunResolution(environment: [:]) + #expect(resolution == nil) + } +} + +struct StepFunUsageFetcherParsingTests { + @Test + func `parses real API response format with string timestamps and integer rates`() throws { + // This matches the actual StepFun API response format: + // - timestamps as strings (e.g. "1777528800") + // - rates can be integers (e.g. 1) or floats (e.g. 0.99781543) + let json = """ + { + "status": 1, + "desc": "", + "five_hour_usage_left_rate": 1, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_left_rate": 0.99781543, + "weekly_usage_reset_time": "1777899600" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 1.0) + #expect(snapshot.weeklyUsageLeftRate > 0.997 && snapshot.weeklyUsageLeftRate < 0.998) + } + + @Test + func `parses response with float rates and integer timestamps`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": 1746000000, + "weekly_usage_reset_time": 1746500000 + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 0.75) + #expect(snapshot.weeklyUsageLeftRate == 0.5) + } + + @Test + func `throws on failed API status`() { + let json = """ + { + "status": 0, + "message": "Unauthorized", + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on missing fields`() { + let json = """ + { + "status": 1 + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `snapshot maps to UsageSnapshot correctly`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // Five-hour window: 20% used (1.0 - 0.8) + let primaryUsed = usage.primary?.usedPercent ?? 0 + #expect(primaryUsed > 19.9 && primaryUsed < 20.1) + + // Weekly window: 40% used (1.0 - 0.6) + let secondaryUsed = usage.secondary?.usedPercent ?? 0 + #expect(secondaryUsed > 39.9 && secondaryUsed < 40.1) + #expect(usage.secondary?.windowMinutes == 10080) + + // Identity + #expect(usage.identity?.providerID == .stepfun) + #expect(usage.identity?.loginMethod == "password") + } + + @Test + func `clamps used percent to 0-100 range`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.0, + "weekly_usage_left_rate": 1, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // 0% remaining → 100% used + #expect(usage.primary?.usedPercent == 100.0) + // 100% remaining → 0% used (integer 1 parsed as 1.0) + #expect(usage.secondary?.usedPercent == 0.0) + } +} + +struct StepFunTokenNormalizerTests { + @Test + func `extracts Oasis-Token from cookie header`() { + let input = "Oasis-Token=abc123...def456; Oasis-Webid=someid" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns raw value when not a cookie header`() { + let input = "abc123...def456" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns empty for empty string`() { + #expect(StepFunTokenNormalizer.normalize("").isEmpty) + } + + @Test + func `trims whitespace`() { + #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123") + } +} diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index 1390dd9e3..8abc6aed4 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -111,6 +111,34 @@ struct SubprocessRunnerTests { } } + @Test + func `cancellation terminates hung process promptly`() async throws { + let start = Date() + let task = Task { + try await SubprocessRunner.run( + binary: "/bin/sleep", + arguments: ["5"], + environment: ProcessInfo.processInfo.environment, + timeout: 30, + label: "cancelled-hung-process") + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected CancellationError but subprocess completed") + } catch is CancellationError { + // Expected: cancellation should tear down the child process immediately. + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + let elapsed = Date().timeIntervalSince(start) + #expect(elapsed < 2, "Cancelled subprocess should not wait for timeout or natural exit") + } + /// Verify that many concurrent SubprocessRunner calls complete without starving each other. @Test func `concurrent calls do not starve`() async throws { diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index 1a14b6fe4..3be4e9118 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -140,6 +140,24 @@ struct TTYCommandRunnerEnvTests { #expect((merged["PATH"] ?? "").contains("/custom/bin")) } + @Test + func `codex status probe uses non persistent thread storage`() { + let stateHome = URL(fileURLWithPath: "/tmp/codexbar status \"state\"", isDirectory: true) + let args = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome) + + #expect(args.starts(with: ["-s", "read-only", "-a", "untrusted"])) + #expect(args.contains("history.persistence=\"none\"")) + #expect(args.contains("experimental_thread_store={type=\"in_memory\",id=\"codexbar-status\"}")) + #expect(args.contains("sqlite_home=\"/tmp/codexbar status \\\"state\\\"\"")) + } + + @Test + func `codex status probe avoids root working directory when home exists`() { + let home = "/Users/tester" + let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: ["HOME": home]) + #expect(workingDirectory?.path == home) + } + @Test func `sets working directory when provided`() throws { let fm = FileManager.default diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index b7dd4ec9e..b11eb1814 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -55,4 +55,45 @@ struct TTYIntegrationTests { if !shouldAssert { return } } + + @Test + func `claude pty usage waits for values after session label`() async throws { + let cli = try Self.makeSlowUsageClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 8).fetch() + } + + #expect(snapshot.sessionPercentLeft == 93) + #expect(snapshot.weeklyPercentLeft == 79) + } + + private static func makeSlowUsageClaudeCLI() 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' 'Settings Status Config Usage' + printf '%s\\n' 'Current session' + sleep 4 + printf '%s\\n' '93% left' + printf '%s\\n' 'Current week (all models)' + printf '%s\\n' '79% left' + ;; + *"/status"*) + printf '%s\\n' 'Account: slow-usage@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/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 47d103d83..185248593 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -1,6 +1,9 @@ import CodexBarCore import Foundation @testable import CodexBar +#if os(macOS) +import AppKit +#endif final class InMemoryCookieHeaderStore: CookieHeaderStoring, @unchecked Sendable { var value: String? @@ -133,6 +136,48 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto return CodexBarConfigStore(fileURL: url) } +#if os(macOS) +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) throws -> T) rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try operation(controller) +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) async throws -> T) async rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try await operation(controller) +} +#endif + func testPlanUtilizationHistoryStore(suiteName: String, reset: Bool = true) -> PlanUtilizationHistoryStore { let sanitized = suiteName.replacingOccurrences(of: "/", with: "-") let base = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 414352959..6460c95eb 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -24,6 +24,21 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-deepseek-app") + settings.addTokenAccount(provider: .deepseek, label: "Account 1", token: "account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .deepseek, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `token account environment overrides config API key in CLI environment builder`() throws { let config = CodexBarConfig( @@ -45,6 +60,23 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in CLI environment builder`() throws { + let config = CodexBarConfig(providers: []) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + let env = tokenContext.environment(base: [:], provider: .deepseek, account: account) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `ollama token account selection forces manual cookie source in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( @@ -90,6 +122,44 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") } + @Test + func `claude session account strips ambient admin api credentials in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-admin-strip-app") + settings.claudeAdminAPIKey = "sk-ant-admin-config" + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + + let env = ProviderRegistry.makeEnvironment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `claude session key selection carries organization id in app settings snapshot`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-org-app") + settings.addTokenAccount( + provider: .claude, + label: "Team", + token: "sk-ant-session-token", + organizationID: " org-team ") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + #expect(claudeSettings.organizationID == "org-team") + } + @Test func `claude OAuth token selection forces OAuth in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( @@ -148,6 +218,45 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") } + @Test + func `claude session account strips ambient admin api credentials in CLI environment builder`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + apiKey: "sk-ant-admin-config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + @Test func `claude OAuth token selection promotes auto source mode in CLI`() throws { let account = ProviderTokenAccount( @@ -170,6 +279,112 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(effectiveSourceMode == .oauth) } + @Test + func `claude OAuth token selection reroutes explicit CLI source to OAuth in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude session key selection reroutes explicit CLI source to Web in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .web) + } + + @Test + func `claude all accounts reroutes explicit CLI source per selected credential in CLI`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "OAuth", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ProviderTokenAccount( + id: UUID(), + label: "Session", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), + config: config, + verbose: false) + + let resolved = try tokenContext.resolvedAccounts(for: .claude) + #expect(resolved.map(\.label) == ["OAuth", "Session"]) + + let oauth = try #require(resolved.first) + let oauthSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: oauth)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: oauth) == .oauth) + #expect(oauthSnapshot.usageDataSource == .oauth) + #expect(tokenContext.environment(base: [:], provider: .claude, account: oauth)[ + ClaudeOAuthCredentialsStore.environmentTokenKey, + ] == "sk-ant-oat-account-token") + + let session = try #require(resolved.dropFirst().first) + let sessionSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: session)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: session) == .web) + #expect(sessionSnapshot.cookieSource == .manual) + #expect(sessionSnapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `claude ambient explicit CLI source remains CLI in CLI`() throws { + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .cli) + } + @Test func `claude session key selection stays in manual cookie mode in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( @@ -201,6 +416,55 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") } + @Test + func `claude session key selection carries organization id in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil, + organizationID: " org-team "), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.organizationID == "org-team") + } + + @Test + func `claude token account organization id uses organizationId JSON key`() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "label": "Team", + "token": "sk-ant-session-token", + "addedAt": 0, + "lastUsed": null, + "organizationId": "org-team" + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(account)) as? [String: Any] + + #expect(account.organizationID == "org-team") + #expect(encoded?["organizationId"] as? String == "org-team") + #expect(encoded?["organizationID"] == nil) + } + @Test func `claude config manual cookie uses shared route in CLI settings snapshot`() throws { let config = CodexBarConfig( @@ -300,8 +564,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: []) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -347,8 +613,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: [managedAccount]) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -395,8 +663,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: [managedAccount]) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -436,11 +706,16 @@ struct TokenAccountEnvironmentPrecedenceTests { settings: settings) } - private static func codexCLIKnownOwners() throws -> [CodexDashboardKnownOwnerCandidate]? { + private static func codexCLIKnownOwners( + ambientHome: URL, + managedStoreURL: URL) throws -> [CodexDashboardKnownOwnerCandidate]? + { let context = try TokenAccountCLIContext( selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), config: CodexBarConfig(providers: [ProviderConfig(id: .codex)]), - verbose: false) + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: managedStoreURL) return context.settingsSnapshot(for: .codex, account: nil)?.codex?.dashboardAuthorityKnownOwners } @@ -490,38 +765,20 @@ struct TokenAccountEnvironmentPrecedenceTests { private static func withCLIKnownOwnerFixtures( ambientHome: URL, managedAccounts: [ManagedCodexAccount], - operation: () throws -> T) throws -> T + operation: (URL) throws -> T) throws -> T { - let managedStoreURL = FileManagedCodexAccountStore.defaultURL() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-store-\(UUID().uuidString)", isDirectory: true) + let managedStoreURL = root.appendingPathComponent("managed-codex-accounts.json", isDirectory: false) let fileManager = FileManager.default - let originalManagedStoreData = try? Data(contentsOf: managedStoreURL) - let hadOriginalManagedStore = fileManager.fileExists(atPath: managedStoreURL.path) - let originalCodexHome = getenv("CODEX_HOME").map { String(cString: $0) } + defer { try? fileManager.removeItem(at: root) } let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) try managedStore.storeAccounts(ManagedCodexAccountSet( version: FileManagedCodexAccountStore.currentVersion, accounts: managedAccounts)) - setenv("CODEX_HOME", ambientHome.path, 1) - - defer { - if let originalCodexHome { - setenv("CODEX_HOME", originalCodexHome, 1) - } else { - unsetenv("CODEX_HOME") - } - - if hadOriginalManagedStore, let originalManagedStoreData { - try? fileManager.createDirectory( - at: managedStoreURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - try? originalManagedStoreData.write(to: managedStoreURL, options: [.atomic]) - } else { - try? fileManager.removeItem(at: managedStoreURL) - } - } - return try operation() + return try operation(managedStoreURL) } private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index c6804a719..15cd68994 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -21,9 +21,11 @@ struct UsageFormatterTests { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) - #expect(text.contains("Updated")) - // Check for relative time format (varies by locale: "ago" in English, "전" in Korean, etc.) - #expect(text.contains("5") || text.lowercased().contains("hour") || text.contains("시간")) + #expect(text.hasPrefix("Updated ")) + // Output must stay in English regardless of the host system locale, + // matching the surrounding hardcoded English UI labels. + #expect(text.contains("5")) + #expect(text.lowercased().contains("ago")) } @Test @@ -222,4 +224,14 @@ struct UsageFormatterTests { let result = UsageFormatter.creditsString(from: 42.5) #expect(result == "42.5 left") } + + @Test + func `byte count string formats binary units`() { + #expect(UsageFormatter.byteCountString(0) == "0 B") + #expect(UsageFormatter.byteCountString(512) == "512 B") + #expect(UsageFormatter.byteCountString(1536) == "1.5 KB") + #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") + #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") + #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") + } } diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index c1bfd769a..0de92b098 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -75,4 +75,23 @@ struct UsagePaceTests { #expect(pace == nil) } + + @Test + func `session pace computes delta and eta for five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) + + #expect(pace != nil) + guard let pace else { return } + #expect(abs(pace.expectedUsedPercent - 60.0) < 0.01) + #expect(abs(pace.deltaPercent - -10.0) < 0.01) + #expect(pace.stage == .behind) + #expect(pace.willLastToReset == true) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 987f2d1e8..8de0e8156 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -67,4 +67,85 @@ struct UsagePaceTextTests { #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") } + + // MARK: - Session pace (5-hour window) + + @Test + func `session pace detail provides left right labels`() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed out of 5h + // expected = 60%, actual = 80% => 20% ahead (in deficit) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") + #expect(detail?.stage == .farAhead) + } + + @Test + func `session pace detail reports lasts until reset`() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed + // expected = 60%, actual = 10% => far behind (in reserve) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "50% in reserve") + #expect(detail?.rightLabel == "Lasts until reset") + } + + @Test + func `session pace summary formats single line text`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now) + + #expect(summary == "Pace: 20% in deficit · Projected empty in 45m") + } + + @Test + func `session pace detail hides for unsupported provider`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .zai, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func `session pace detail hides when reset is missing`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail == nil) + } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index b5710878a..eab385256 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -389,6 +389,15 @@ struct UsageStoreCoverageTests { #expect(gate.streak == 0) } + @Test + func `token account error message ignores cancellation`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-token-account-cancel") + let store = Self.makeUsageStore(settings: settings) + + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(ProviderFetchError.noAvailableStrategy(.copilot)) != nil) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), @@ -399,7 +408,7 @@ struct UsageStoreCoverageTests { defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: zaiTokenStore, @@ -417,6 +426,8 @@ struct UsageStoreCoverageTests { ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings } private static func makeUsageStore(settings: SettingsStore) -> UsageStore { diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index 11aabaf55..856fc178c 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -29,4 +29,27 @@ struct UsageStorePathDebugTests { #expect(store.pathDebugInfo != .empty) #expect(store.pathDebugInfo.effectivePATH.isEmpty == false) } + + @Test + func `deepseek debug log includes selected token account`() async throws { + let suite = "UsageStorePathDebugTests-deepseek-debug-token-account" + 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()) + settings.addTokenAccount(provider: .deepseek, label: "Primary", token: "sk-deepseek-test") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.deepseek) + + #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") + } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 452d0cfd0..2ccc89a23 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -129,4 +129,116 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) } + + @Test + func `same claude email separates team and personal plan history keys`() throws { + let team = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let max = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let teamKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: team)) + let maxKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: max)) + + #expect(teamKey != maxKey) + } + + @Test + func `claude email only identity keeps legacy history key`() throws { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let identityKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + + #expect(identityKey == legacyKey) + } + + @Test + func `claude compact and branded plan labels share history key`() throws { + let compact = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Max")) + let branded = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let compactKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: compact)) + let brandedKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: branded)) + + #expect(compactKey == brandedKey) + } + + @MainActor + @Test + func `new claude email discriminator adopts legacy email history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + let accountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + preferredAccountKey: legacyKey, + accounts: [ + legacyKey: [legacyWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [legacyWeekly]) + #expect(buckets.accounts[legacyKey] == nil) + #expect(buckets.accounts[accountKey] == [legacyWeekly]) + #expect(buckets.preferredAccountKey == accountKey) + } } diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d9..1798b8eb4 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -1,25 +1,40 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct UsageStoreSessionQuotaTransitionTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + @MainActor final class SessionQuotaNotifierSpy: SessionQuotaNotifying { private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { + self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + } } @Test func `copilot switch from primary to secondary resets baseline`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -48,10 +63,7 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `copilot switch from secondary to primary resets baseline`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -77,4 +89,465 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `claude spend limit fallback does not emit session or quota warning notifications`() throws { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-spend-limit") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let claude = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + let snapshot = ClaudeOAuthFetchStrategy._snapshotForTesting(from: claude) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.period == "Spend limit") + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.claude]) + } + + @Test + func `quota warning disabled does not post`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = false + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleQuotaWarningTransitions(provider: .codex, snapshot: snapshot) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `quota warning posts once per downward threshold crossing`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-once") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.window == .session) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == "person@example.com") + } + + @Test + func `quota warning omits account when personal info is hidden`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-account-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.hidePersonalInfo = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == nil) + } + + @Test + func `hidden quota warning markers do not disable warning notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-markers-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningMarkersVisible = false + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + } + + @Test + func `quota warning crossing multiple thresholds posts most severe only`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-severe") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [20]) + } + + @Test + func `quota warning recovers and can fire again`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-recover") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + for used in [40, 55, 10, 55] { + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: Double(used), + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date())) + } + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `quota warning provider override beats global thresholds`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-override") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [10]) + } + + @Test + func `quota warning session only config ignores weekly crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-session-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `quota warning weekly only config ignores session crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-weekly-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) + } + + @Test + func `disabling quota warning window clears fired state`() { + let settings = self + .makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled-clears-state") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) + } } diff --git a/Tests/CodexBarTests/VeniceSettingsReaderTests.swift b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift new file mode 100644 index 000000000..bc44a0645 --- /dev/null +++ b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct VeniceSettingsReaderTests { + @Test + func `reads VENICE_API_KEY`() { + let env = ["VENICE_API_KEY": "ven-abc123"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-abc123") + } + + @Test + func `falls back to VENICE_KEY`() { + let env = ["VENICE_KEY": "ven-fallback"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-fallback") + } + + @Test + func `VENICE_API_KEY takes priority over VENICE_KEY`() { + let env = ["VENICE_API_KEY": "ven-primary", "VENICE_KEY": "ven-secondary"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-primary") + } + + @Test + func `trims whitespace`() { + let env = ["VENICE_API_KEY": " ven-trimmed "] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["VENICE_API_KEY": "\"ven-quoted\""] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["VENICE_KEY": "'ven-single'"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-single") + } + + @Test + func `returns nil when no key present`() { + #expect(VeniceSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["VENICE_API_KEY": ""] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["VENICE_API_KEY": " "] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } +} + +struct VeniceProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["VENICE_API_KEY": "ven-resolve-test"] + let resolution = ProviderTokenResolver.veniceResolution(environment: env) + #expect(resolution?.token == "ven-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.veniceResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/VeniceUsageFetcherTests.swift b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift new file mode 100644 index 000000000..fc226997c --- /dev/null +++ b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift @@ -0,0 +1,297 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct VeniceUsageFetcherTests { + @Test + func `parses DIEM balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.50, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "DIEM") + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == nil) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses USD balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 25.75 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "USD") + #expect(snapshot.diemBalance == nil) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == nil) + } + + @Test + func `parses string-encoded balances and allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": "90.50", + "usd": "25.75" + }, + "diemEpochAllocation": "100.0" + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses both DIEM and USD present`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 50.0) + #expect(snapshot.usdBalance == 10.0) + } + + @Test + func `uses DIEM allocation progress for bundled credits currency`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 50.0) + } + + @Test + func `uses USD display when consumptionCurrency is USD and both balances exist`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 50.0, + "usd": 12.34 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "$12.34 USD remaining") + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles canConsume=false`() throws { + let json = """ + { + "canConsume": false, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 100.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "Balance unavailable for API calls") + } + + @Test + func `displays DIEM with epoch allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 75.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 75.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 25.0) + } + + @Test + func `displays DIEM without allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 50.0, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 remaining") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `displays USD balance`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 15.50 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("$15.50") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles zero balances`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 0.0, + "usd": 0.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `handles null balances with canConsume=true`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": null, + "balances": { + "diem": null, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `identity uses venice provider ID`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .venice) + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } + + @Test + func `throws on malformed JSON`() { + let json = "[{ \"canConsume\": true }]" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `throws on invalid JSON`() { + let json = "{ invalid json }" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `clamps used percent to 0-100 range`() throws { + // Negative used percent should be clamped to 0 + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 150.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + } +} diff --git a/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift new file mode 100644 index 000000000..9527fdb3c --- /dev/null +++ b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct VertexAIOAuthCredentialsTests { + @Test + func `service account credentials from GOOGLE_APPLICATION_CREDENTIALS use gcloud token`() async throws { + let fileURL = try Self.writeServiceAccountCredentials() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + let env = ["GOOGLE_APPLICATION_CREDENTIALS": fileURL.path] + + #expect(VertexAIOAuthCredentialsStore.hasCredentials(environment: env)) + + let override: @Sendable ([String: String]) async throws -> String = { environment in + #expect(environment["GOOGLE_APPLICATION_CREDENTIALS"] == fileURL.path) + return "ya29.service-account\n" + } + let credentials = try await VertexAIOAuthCredentialsStore.$gcloudAccessTokenOverrideForTesting.withValue( + override) + { + try await VertexAIOAuthCredentialsStore.loadForFetch(environment: env) + } + + #expect(credentials.accessToken == "ya29.service-account") + #expect(credentials.projectId == "service-project") + #expect(credentials.email == "codexbar@test.iam.gserviceaccount.com") + #expect(!credentials.needsRefresh) + } + + @Test + func `user ADC credentials still parse from CLOUDSDK_CONFIG`() throws { + let configDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: configDir) } + + let credentialsURL = configDir.appendingPathComponent("application_default_credentials.json") + let credentialsJSON = """ + { + "client_id": "client-id", + "client_secret": "client-secret", + "refresh_token": "refresh-token" + } + """ + try credentialsJSON.write(to: credentialsURL, atomically: true, encoding: .utf8) + + let configurationsDir = configDir + .appendingPathComponent("configurations", isDirectory: true) + try FileManager.default.createDirectory(at: configurationsDir, withIntermediateDirectories: true) + try "project = configured-project\n".write( + to: configurationsDir.appendingPathComponent("config_default"), + atomically: true, + encoding: .utf8) + + let env = ["CLOUDSDK_CONFIG": configDir.path] + let credentials = try VertexAIOAuthCredentialsStore.load(environment: env) + + #expect(credentials.refreshToken == "refresh-token") + #expect(credentials.projectId == "configured-project") + } + + private static func writeServiceAccountCredentials() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("service-account.json") + let json = """ + { + "type": "service_account", + "project_id": "service-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\\n", + "client_email": "codexbar@test.iam.gserviceaccount.com", + "client_id": "1234567890", + "token_uri": "https://oauth2.googleapis.com/token" + } + """ + try json.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } +} diff --git a/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift new file mode 100644 index 000000000..6d9a6ed72 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct WindsurfDevinSessionImporterTests { + @Test + func `defaults to Chrome before fallback Chromium browsers`() { + #expect(WindsurfDevinSessionImporter.defaultPreferredBrowsers == [.chrome]) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsers.contains(.chrome)) + #expect(WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).first == .chromeBeta) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).contains(.edge)) + } + + @Test + func `decodes quoted local storage strings`() { + #expect(WindsurfDevinSessionImporter + .decodedStorageValue(#""devin-session-token$abc""#) == "devin-session-token$abc") + #expect(WindsurfDevinSessionImporter.decodedStorageValue("auth1_xyz") == "auth1_xyz") + } + + @Test + func `builds session only when all local storage keys exist`() { + let storage = [ + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456", + ] + + let session = WindsurfDevinSessionImporter.session(from: storage, sourceLabel: "Chrome Default") + + #expect(session?.session.sessionToken == "devin-session-token$abc") + #expect(session?.session.auth1Token == "auth1_xyz") + #expect(session?.session.accountID == "account-123") + #expect(session?.session.primaryOrgID == "org-456") + #expect(session?.sourceLabel == "Chrome Default") + } + + @Test + func `deduplicates repeated session tokens while preserving first source`() { + let sessions = [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_xyz", + accountID: "account-123", + primaryOrgID: "org-456"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_other", + accountID: "account-999", + primaryOrgID: "org-999"), + sourceLabel: "Chrome Profile 1"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$def", + auth1Token: "auth1_def", + accountID: "account-456", + primaryOrgID: "org-789"), + sourceLabel: "Chrome Profile 2"), + ] + + let deduplicated = WindsurfDevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 2) + #expect(deduplicated[0].sourceLabel == "Chrome Default") + #expect(deduplicated[0].session.sessionToken == "devin-session-token$abc") + #expect(deduplicated[1].session.sessionToken == "devin-session-token$def") + } +} diff --git a/Tests/CodexBarTests/WindsurfProviderTests.swift b/Tests/CodexBarTests/WindsurfProviderTests.swift new file mode 100644 index 000000000..c0b9dd54c --- /dev/null +++ b/Tests/CodexBarTests/WindsurfProviderTests.swift @@ -0,0 +1,55 @@ +import Testing +@testable import CodexBarCore + +struct WindsurfProviderTests { + private func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `local probe is unavailable in explicit web mode`() async { + let strategy = WindsurfLocalFetchStrategy() + + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .web)) == false) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .auto))) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .cli))) + } + + @Test + func `web mode with cookies off does not fall back to local probe`() async { + let settings = ProviderSettingsSnapshot.make( + windsurf: .init( + usageDataSource: .web, + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(sourceMode: .web, settings: settings) + + let outcome = await WindsurfProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .windsurf) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected web-only Windsurf fetch to fail when cookies are off") + return + } + + #expect(error is ProviderFetchError) + #expect(outcome.attempts.map(\.strategyID) == ["windsurf.web", "windsurf.local"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false]) + } +} diff --git a/Tests/CodexBarTests/WindsurfStatusProbeTests.swift b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift new file mode 100644 index 000000000..42524a9e7 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import SQLite3 +import Testing + +struct WindsurfStatusProbeTests { + // MARK: - Helper + + private static func decode(_ json: String) throws -> WindsurfCachedPlanInfo { + try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(json.utf8)) + } + + // MARK: - JSON Decoding + + @Test + func `decodes full plan info`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, + "usedMessages": 35650, + "remainingMessages": 14350, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, + "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, + "weeklyResetAtUnix": 1774166400 + } + } + """) + + #expect(info.planName == "Pro") + #expect(info.startTimestamp == 1_771_610_750_000) + #expect(info.endTimestamp == 1_774_029_950_000) + #expect(info.usage?.messages == 50000) + #expect(info.usage?.usedMessages == 35650) + #expect(info.usage?.remainingMessages == 14350) + #expect(info.usage?.flowActions == 150_000) + #expect(info.usage?.usedFlowActions == 0) + #expect(info.usage?.remainingFlowActions == 150_000) + #expect(info.quotaUsage?.dailyRemainingPercent == 9) + #expect(info.quotaUsage?.weeklyRemainingPercent == 54) + #expect(info.quotaUsage?.dailyResetAtUnix == 1_774_080_000) + #expect(info.quotaUsage?.weeklyResetAtUnix == 1_774_166_400) + } + + @Test + func `decodes minimal plan info`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + #expect(info.planName == "Free") + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + #expect(info.endTimestamp == nil) + } + + @Test + func `decodes empty object`() throws { + let info = try Self.decode("{}") + + #expect(info.planName == nil) + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + } + + // MARK: - toUsageSnapshot Conversion + + @Test + func `converts full plan to usage snapshot`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, "usedMessages": 35650, "remainingMessages": 14350, + "flowActions": 150000, "usedFlowActions": 0, "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + // Primary = daily: usedPercent = 100 - 9 = 91 + #expect(snapshot.primary?.usedPercent == 91) + #expect(snapshot.primary?.resetsAt != nil) + + // Secondary = weekly: usedPercent = 100 - 54 = 46 + #expect(snapshot.secondary?.usedPercent == 46) + #expect(snapshot.secondary?.resetsAt != nil) + + // Identity + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.identity?.accountOrganization != nil) + } + + @Test + func `converts minimal plan to usage snapshot`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + let snapshot = info.toUsageSnapshot() + + // Without quotaUsage, primary and secondary should be nil + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Free") + #expect(snapshot.identity?.accountOrganization == nil) + } + + @Test + func `converts usage counts when quota usage is absent`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 50000, + "usedMessages": 1200, + "remainingMessages": 48800, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000, + "flexCredits": 123700, + "usedFlexCredits": 0, + "remainingFlexCredits": 123700 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 2.4) + #expect(snapshot.primary?.resetDescription == "1200 / 50000 messages") + #expect(snapshot.secondary?.usedPercent == 0) + #expect(snapshot.secondary?.resetDescription == "0 / 150000 flow actions") + } + + @Test + func `usage counts infer used amount from remaining`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 100, + "remainingMessages": 25 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.primary?.resetDescription == "75 / 100 messages") + #expect(snapshot.secondary == nil) + } + + @Test + func `daily at zero remaining shows 100 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 0, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `weekly at full remaining shows 0 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 100, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `reset dates are correctly converted from unix timestamps`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": { + "dailyRemainingPercent": 50, "weeklyRemainingPercent": 50, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.resetsAt == Date(timeIntervalSince1970: 1_774_080_000)) + #expect(snapshot.secondary?.resetsAt == Date(timeIntervalSince1970: 1_774_166_400)) + } + + @Test + func `end timestamp converts to expiry description`() throws { + let futureMs = Int64(Date().addingTimeInterval(86400 * 30).timeIntervalSince1970 * 1000) + let info = try Self.decode(""" + {"planName": "Pro", "endTimestamp": \(futureMs)} + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.identity?.accountOrganization?.hasPrefix("Expires ") == true) + } + + // MARK: - Probe Database Decoding + + @Test + func `probe decodes UTF-8 JSON blob`() throws { + let dbURL = try Self.makeTemporaryDatabase( + jsonData: Data(#"{"planName":"UTF-8 Pro"}"#.utf8)) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-8 Pro") + } + + @Test + func `probe decodes UTF-16LE JSON blob`() throws { + let jsonData = try #require(#"{"planName":"UTF-16 Pro"}"#.data(using: .utf16LittleEndian)) + let dbURL = try Self.makeTemporaryDatabase(jsonData: jsonData) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-16 Pro") + } + + // MARK: - Probe Error Cases + + @Test + func `probe throws dbNotFound for missing file`() { + let probe = WindsurfStatusProbe(dbPath: "/nonexistent/path/state.vscdb") + + #expect(throws: WindsurfStatusProbeError.self) { + _ = try probe.fetch() + } + } + + private static func makeTemporaryDatabase(jsonData: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("windsurf-status-probe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let dbURL = directory.appendingPathComponent("state.vscdb") + + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { + throw TestSQLiteError.openFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + try self.execute( + """ + CREATE TABLE ItemTable( + key TEXT PRIMARY KEY, + value BLOB + ); + """, + db: db) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO ItemTable(key, value) VALUES('windsurf.settings.cachedPlanInfo', ?);", + -1, + &stmt, + nil) == SQLITE_OK + else { + throw TestSQLiteError.prepareFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let bindResult = jsonData.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 1, buffer.baseAddress, Int32(jsonData.count), transient) + } + guard bindResult == SQLITE_OK else { + throw TestSQLiteError.bindFailed(String(cString: sqlite3_errmsg(db))) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw TestSQLiteError.stepFailed(String(cString: sqlite3_errmsg(db))) + } + + return dbURL + } + + private static func execute(_ sql: String, db: OpaquePointer?) throws { + var errorMessage: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &errorMessage) == SQLITE_OK else { + defer { sqlite3_free(errorMessage) } + let message = errorMessage.map { String(cString: $0) } ?? "unknown error" + throw TestSQLiteError.execFailed(message) + } + } + + private enum TestSQLiteError: Error { + case openFailed(String) + case execFailed(String) + case prepareFailed(String) + case bindFailed(String) + case stepFailed(String) + } +} diff --git a/Tests/CodexBarTests/WindsurfWebFetcherTests.swift b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift new file mode 100644 index 000000000..c6f3e571a --- /dev/null +++ b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift @@ -0,0 +1,474 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct WindsurfWebFetcherTests { + private struct ResponseFixture { + let planName: String + let dailyRemaining: Int + let weeklyRemaining: Int + let planEndUnix: Int64 + let dailyResetUnix: Int64 + let weeklyResetUnix: Int64 + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [WindsurfWebFetcherStubURLProtocol.self] + return URLSession(configuration: config) + } + + @Test + func `manual devin session sends protobuf request and auth headers`() async throws { + defer { + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.host == "windsurf.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/proto") + #expect(request.value(forHTTPHeaderField: "Connect-Protocol-Version") == "1") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://windsurf.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://windsurf.com/profile") + #expect(request.value(forHTTPHeaderField: "x-auth-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-session-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-auth1-token") == "auth1_xyz") + #expect(request.value(forHTTPHeaderField: "x-devin-account-id") == "account-123") + #expect(request.value(forHTTPHeaderField: "x-devin-primary-org-id") == "org-456") + + let body = try WindsurfPlanStatusProtoCodec.decodeRequest(Self.requestBodyData(from: request)) + #expect(body.authToken == "devin-session-token$abc") + #expect(body.includeTopUpStatus == true) + + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Pro", + dailyRemaining: 68, + weeklyRemaining: 84, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let manualSession = """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456" + } + """ + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: manualSession, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 1) + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.primary?.usedPercent == 32) + #expect(snapshot.secondary?.usedPercent == 16) + } + + @Test + func `auto session import retries next profile after auth failure`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Chrome Profile 1"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 75, + weeklyRemaining: 90, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.secondary?.usedPercent == 10) + } + + @Test + func `auto session import tries fallback browsers after preferred sessions fail`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-chrome-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-edge-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Microsoft Edge Default"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-chrome-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-edge-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 64, + weeklyRemaining: 80, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 36) + #expect(snapshot.secondary?.usedPercent == 20) + } + + @Test + func `manual mode with empty session does not fall back to imported session`() async { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "auto-token", + auth1Token: "auto-auth1", + accountID: "auto-account", + primaryOrgID: "auto-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfWebFetcherStubURLProtocol.requests = [] + + await #expect { + _ = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: " \n", + timeout: 2, + session: self.makeSession()) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + #expect(WindsurfWebFetcherStubURLProtocol.requests.isEmpty) + } + + @Test + func `manual key value session input is accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + devin_session_token=devin-session-token$abc + devin_auth1_token=auth1_xyz + devin_account_id=account-123 + devin_primary_org_id=org-456 + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual JSON camelCase aliases are accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devinSessionToken": "devin-session-token$abc", + "devinAuth1Token": "auth1_xyz", + "devinAccountId": "account-123", + "devinPrimaryOrgId": "org-456" + } + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual session input rejects empty string`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput(" \n") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + } + + @Test + func `manual session input rejects invalid text`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput("not a valid session bundle") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + @Test + func `manual session input rejects missing required fields`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123" + } + """) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + private static func makeResponse( + url: URL, + body: Data, + contentType: String, + statusCode: Int) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, body) + } + + private static func requestBodyData(from request: URLRequest) -> Data { + if let data = request.httpBody { + return data + } + + guard let stream = request.httpBodyStream else { + return Data() + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + + return data + } + + private static func makePlanStatusResponse(_ fixture: ResponseFixture) -> Data { + let planInfo = self.message([ + self.stringField(2, fixture.planName), + ]) + + let planStatus = self.message([ + self.messageField(1, planInfo), + self.messageField(3, self.timestamp(seconds: fixture.planEndUnix)), + self.varintField(14, UInt64(fixture.dailyRemaining)), + self.varintField(15, UInt64(fixture.weeklyRemaining)), + self.varintField(17, UInt64(fixture.dailyResetUnix)), + self.varintField(18, UInt64(fixture.weeklyResetUnix)), + ]) + + return self.message([ + self.messageField(1, planStatus), + ]) + } + + private static func timestamp(seconds: Int64) -> Data { + self.message([ + self.varintField(1, UInt64(seconds)), + ]) + } + + private static func message(_ fields: [Data]) -> Data { + fields.reduce(into: Data()) { partialResult, field in + partialResult.append(field) + } + } + + private static func stringField(_ number: Int, _ value: String) -> Data { + self.lengthDelimitedField(number, Data(value.utf8)) + } + + private static func messageField(_ number: Int, _ value: Data) -> Data { + self.lengthDelimitedField(number, value) + } + + private static func lengthDelimitedField(_ number: Int, _ value: Data) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 2)) + data.append(self.varint(UInt64(value.count))) + data.append(value) + return data + } + + private static func varintField(_ number: Int, _ value: UInt64) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 0)) + data.append(self.varint(value)) + return data + } + + private static func fieldKey(_ number: Int, wireType: UInt64) -> Data { + self.varint(UInt64((number << 3) | Int(wireType))) + } + + private static func varint(_ value: UInt64) -> Data { + var remaining = value + var data = Data() + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + return data + } +} + +final class WindsurfWebFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + 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/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index ce23d2351..64c1bdbfa 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -227,6 +227,49 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.usage == 40_000_000) #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") #expect(snapshot.tokenLimit?.percentage == 34.0) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") + } + + @Test + func `zai mcp time limit displays monthly instead of one minute window`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.timeLimit?.windowDescription == "1 minute") + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") } @Test @@ -322,6 +365,94 @@ struct ZaiUsageParsingTests { } } +struct ZaiHourlyUsageTests { + @Test + func `model usage parser decodes hourly model payload`() throws { + let json = """ + { + "code": 200, + "msg": "success", + "success": true, + "data": { + "x_time": ["2026-05-14 08:00", "2026-05-14 09:00"], + "modelDataList": [ + { "modelName": "glm-4.6", "tokensUsage": [100, null] }, + { "modelName": "glm-4.5", "tokensUsage": [50, 25] } + ] + } + } + """ + + let usage = try ZaiUsageFetcher.parseModelUsage(from: Data(json.utf8)) + + #expect(usage.xTime == ["2026-05-14 08:00", "2026-05-14 09:00"]) + #expect(usage.modelNames == ["glm-4.6", "glm-4.5"]) + #expect(usage.modelDataList[0].tokensUsage == [100, nil]) + #expect(usage.modelDataList[1].tokensUsage == [50, 25]) + } + + @Test + func `today hourly bars filter earlier days and skip empty hours`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(yesterday), + "2026-05-14 08:00", + "2026-05-14 09:00", + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [999, 100, 0]), + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [0, 50, nil]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .today(referenceDate: reference), now: reference) + + #expect(bars.map(\.label) == ["08"]) + #expect(bars.first?.totalTokens == 150) + #expect(bars.first?.segments.count == 2) + } + + @Test + func `last 24 hour bars filter data outside trailing window`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let old = Calendar.current.date(byAdding: .hour, value: -25, to: reference) ?? reference + let inWindow = Calendar.current.date(byAdding: .hour, value: -23, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(old), + Self.hourString(inWindow), + Self.hourString(reference), + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [10, 20, 30]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .last24h, now: reference) + + #expect(bars.map(\.label) == [Self.hourLabel(inWindow), Self.hourLabel(reference)]) + #expect(bars.map(\.totalTokens) == [20, 30]) + } + + private static func localDate(year: Int, month: Int, day: Int, hour: Int) -> Date { + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour)) ?? Date() + } + + private static func hourString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } + + private static func hourLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } +} + struct ZaiThreeLimitTests { @Test func `parses three limit entries into session weekly and mcp slots`() throws { diff --git a/appcast.xml b/appcast.xml index a63ac1b25..6e2da979e 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,131 +3,108 @@ CodexBar - 0.23 - Sun, 26 Apr 2026 04:51:49 +0100 + 0.26.1 + Fri, 15 May 2026 15:34:16 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 58 - 0.23 + 63 + 0.26.1 14.0 - CodexBar 0.23 -

Highlights

+ CodexBar 0.26.1 +

Added

    -
  • Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic!
  • -
  • Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy!
  • -
  • Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98!
  • -
  • Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats!
  • -
  • Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models.
  • -
  • Copilot: show a clearer GitHub Device Flow hint in Settings when the copied device code needs to be pasted into GitHub (#369). Thanks @amoranio!
  • +
  • OpenAI API: show Admin API usage inline with Today/7d/30d summaries, a 30-day spend graph, and an interactive detail chart for daily spend, tokens, and requests.
  • +
  • CLI: add codexbar serve for localhost JSON access to usage and cost endpoints (#957). Thanks @ThiagoCAltoe!
-

Fixes

+

Fixed

    -
  • Droid: preserve Factory session fallbacks, use the current usage endpoint, and clarify browser-login messaging (#792). Thanks @JosephDoUrden for the original stale-session fix!
  • -
  • Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch!
  • -
  • Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman!
  • -
  • Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs.
  • -
  • CLI: report the app bundle version correctly when the bundled helper is launched through a symlink.
  • -
  • Codex/Claude: clean up cached CLI status probes during app shutdown so codex -s read-only workers are not orphaned after restart.
  • +
  • Codex: keep background /status probes out of Codex Desktop history by using isolated non-persistent CLI storage (#953).
  • +
  • Menu: stabilize the Cost submenu by using a native menu item and deferring open-menu rebuilds while tracking (#954). Thanks @getogrand!
  • +
  • Localization: add Brazilian Portuguese quota-warning settings strings (#958). Thanks @ThiagoCAltoe!

View full changelog

]]>
- +
- 0.22 - Tue, 21 Apr 2026 01:12:52 +0100 + 0.26.0 + Fri, 15 May 2026 05:30:00 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 57 - 0.22 + 62 + 0.26.0 14.0 - CodexBar 0.22 -

Highlights

+ CodexBar 0.26.0 +

Added

    -
  • Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry.
  • -
  • Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish!
  • -
  • Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang!
  • -
  • Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee!
  • -
  • Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
  • +
  • Codex: add tiered long-context and Fast/Priority pricing to local cost history using local app-server priority traces (#917). Thanks @iam-brain!
  • +
  • Kiro: show account/auth details, plan labels, credit and bonus-credit balances, overage state, and Kiro-specific menu bar display options (#933, fixes #934). Thanks @solnikhil!
  • +
  • Antigravity: add Google OAuth token-account switching with selected-account refresh persistence (#937, fixes #936). Thanks @hhh2210!
  • +
  • OpenRouter: show daily and weekly API key spend from /api/v1/key in the menu (#685). Thanks @ThiagoCAltoe!
  • +
  • Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe!
  • +
  • Menu: add left/right arrow keyboard navigation for the merged provider switcher (#266).
  • +
  • Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe!
  • +
  • AWS Bedrock: add Cost Explorer usage and monthly budget tracking (#897). Thanks @afalk42!
  • +
  • Kilo: add organization selection, scoped organization fetches, and stacked Kilo usage cards (#920). Thanks @NoeFabris!
  • +
  • Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi!
  • +
  • z.ai: add an hourly per-model token usage chart in the menu (#913). Thanks @n1majne3!
  • +
  • Localization: add Brazilian Portuguese translations (#902). Thanks @ThiagoCAltoe!
  • +
  • Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis!
-

Providers & Usage

+

Fixed

    -
  • Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish!
  • -
  • Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang!
  • -
  • Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael!
  • -
  • Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe!
  • -
  • Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang!
  • -
  • Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna
  • -
-

Menu & Settings

-
    -
  • Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee!
  • -
  • Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
  • -
  • Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal!
  • -
-

Fixes

-
    -
  • Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98!
  • +
  • Codex: show authenticated plan/account rows as "Limits not available" instead of a red no-rate-limit error when Codex reports profile data but no rate-limit windows yet.
  • +
  • Overview: hide provider rows that only contain an error, and avoid showing a one-item Codex System Account submenu.
  • +
  • Menu: disable implicit provider-switcher layer animations and reuse the deferred rebuild path so open menus stay stable under pointer movement (#950).
  • +
  • Menu: defer account-switcher menu rebuilds so switching Codex or token accounts does not send the open menu into a flicker loop (#946, fixes #944). Thanks @kubahasek!
  • +
  • Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad!
  • +
  • Codex: scope local cost history to the selected managed account's CODEX_HOME and label cost cards as local-log estimates (#910).
  • +
  • Cost history: label local log totals as API-rate estimates in menu cards, charts, and CLI output (#926). Thanks @yashiels!
  • +
  • Cursor: open Add Account in the user's browser and import the resulting browser session instead of trapping login in an embedded web view (#922).
  • +
  • Claude: handle Enterprise and organization spend-limit usage across OAuth/web accounts, including null session quota windows, inline spend-limit usage, extra_usage-only responses, and token-account Org ID support (#925, #941, fixes #940). Thanks @clintandrewhall!
  • +
  • OpenCode Go: let automatic cookie import scan all supported browser sources instead of Chrome only (#665).
  • +
  • Copilot: preserve over-quota usage so paid overage can show above 100% instead of clamping to exhausted (#818).
  • +
  • Codex: pause background CLI launches after macOS blocks or quarantines codex, avoiding repeated "Malware Blocked" prompts (#942).
  • +
  • Claude: clarify that local cost/token estimates include cache read/write tokens and may differ from Claude Code /status (#781, #787).
  • +
  • Updates: make the restart/apply-update menu action use Sparkle's prepared install callback on the first click (#947). Thanks @velvet-shark!
  • +
  • Multi-account menus: keep stacked token-account cards capped to current accounts and ignore stale snapshots from removed accounts (#949).
  • +
  • Droid: accept pasted Factory Authorization: Bearer headers and bearer tokens for manual sessions when cookies alone are insufficient (#914).
  • +
  • Menu bar: detect when macOS Tahoe hides CodexBar behind the new Allow in Menu Bar setting and show recovery guidance (#945, fixes #890). Thanks @pdurlej!
  • +
  • CLI: route Claude token-account --source cli reads through the selected OAuth/session credential so --all-accounts no longer relabels ambient CLI usage (#403).
  • +
  • Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable CODEX_HOME (#932, fixes #931). Thanks @ThiagoCAltoe!
  • +
  • Gemini: refresh OAuth credentials when the CLI has a refresh token but no cached access token instead of reporting "not logged in" after authentication (#915).
  • +
  • Gemini: label OAuth-backed API fetches as oauth-api instead of plain api (#930). Thanks @ThiagoCAltoe!
  • +
  • Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain!
  • +
  • Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991!
  • +
  • Menu: middle-truncate long account emails in Codex account controls and keep the Codex account switcher visible during merged-menu refreshes with transient account snapshots.
  • +
  • Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the .lproj directory casing differs (#908).
  • +
  • Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store.
  • +
  • ChatGPT credits: restrict purchase links to real HTTPS chatgpt.com settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe!
  • +
  • z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe!
  • +
  • Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi!
  • +
  • Release: include macOS platform and architecture in notarized app and dSYM asset names (#164).
  • +
  • Upstream tooling: resolve remote default branches and tolerate missing upstream remotes in review scripts (#906).

View full changelog

]]>
- +
- 0.21 - Sat, 18 Apr 2026 19:49:47 +0100 + 0.25.1 + Mon, 11 May 2026 03:41:45 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 56 - 0.21 + 61 + 0.25.1 14.0 - CodexBar 0.21 -

Highlights

-
    -
  • Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE!
  • -
  • Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF!
  • -
  • Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane!
  • -
  • Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205!
  • -
  • z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation.
  • -
  • Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation!
  • -
  • Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee!
  • -
  • Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm!
  • -
  • Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708).
  • -
  • Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram!
  • -
  • Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han!
  • -
-

Providers & Usage

-
    -
  • Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE!
  • -
  • Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like prolite, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709).
  • -
  • Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable.
  • -
  • Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205!
  • -
  • z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation.
  • -
  • Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663).
  • -
  • Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee!
  • -
  • Antigravity: prefer userTier.name over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11!
  • -
  • Ollama: recognize __Secure-session cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee!
  • -
  • OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary!
  • -
  • Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708).
  • -
  • Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han!
  • -
  • OpenCode / OpenCode Go: treat serialized _server auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500.
  • -
  • OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708).
  • -
  • Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram!
  • -
  • Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008!
  • -
-

Menu & Settings

-
    -
  • Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm!
  • -
  • z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662).
  • -
  • Menu bar: avoid redundant merged-icon redraws and make hosted chart submenus load lazily without losing provider context (#708).
  • -
  • Merged menu: when Overview is selected, keep the merged menu bar icon aligned with the first Overview provider in configured order, even while that provider is still loading (#724). Thanks @anirudhvee!
  • -
  • Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled.
  • -
-

Development & Tooling

+ CodexBar 0.25.1 +

Fixed

    -
  • Diagnostics: add lightweight battery instrumentation for menu updates and refresh work (#708).
  • -
  • Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with --clear-adhoc-keychain, and extend the explicit reset path to clear both com.steipete.CodexBar and com.steipete.codexbar.cache. Thanks @magnaprog!
  • +
  • Settings: avoid packaged-app crashes from SwiftPM localization bundle lookup when opening Settings or About (#896, fixes #891). Thanks @lederniermagicien!
  • +
  • CLI: include a VERSION file in standalone release archives so --version reports the release tag outside the app bundle (#898). Thanks @ThiagoCAltoe!
  • +
  • Pi: rebuild stale session cost caches after cache-version migrations so refreshed cost history reflects current scanner data.
  • +
  • Keychain cache: reduce repeated development prompt churn by trusting the bundled helper when writing CodexBar-owned cache items (#888).

View full changelog

]]>
- +
0.14.0 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 072cd9e7f..4a687f423 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -13,9 +13,12 @@ read_when: ### Building and Running ```bash -# Full build, test, package, and launch (recommended) +# Full build, package, and launch (recommended) ./Scripts/compile_and_run.sh +# Also run swift test before packaging/relaunching +./Scripts/compile_and_run.sh --test + # Just build and package (no tests) ./Scripts/package_app.sh @@ -26,7 +29,7 @@ read_when: ### Development Workflow 1. **Make code changes** in `Sources/CodexBar/` -2. **Run** `./Scripts/compile_and_run.sh` to rebuild and launch +2. **Run** `./Scripts/compile_and_run.sh --test` to test, rebuild, and launch 3. **Check logs** in Console.app (filter by "codexbar") 4. **Optional file log**: enable Debug → Logging → "Enable file logging" to write `~/Library/Logs/CodexBar/CodexBar.log` (verbosity defaults to "Verbose") @@ -37,7 +40,9 @@ read_when: You'll see **one keychain prompt per stored credential** on the first launch. This is a **one-time migration** that converts existing keychain items to use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. ### Subsequent Rebuilds -**Zero prompts!** The migration flag is stored in UserDefaults, so future rebuilds won't prompt. +The migration flag is stored in UserDefaults, so migrated CodexBar-owned items should not prompt again. Ad-hoc +signing can still prompt for other keychain surfaces; use `./Scripts/compile_and_run.sh --clear-adhoc-keychain` +when you intentionally want to reset ad-hoc keychain state. ### Why This Happens - Ad-hoc signed development builds change code signature on every rebuild @@ -50,28 +55,20 @@ You'll see **one keychain prompt per stored credential** on the first launch. Th defaults delete com.steipete.codexbar KeychainMigrationV1Completed ``` -## Auto-Refresh for Augment Cookies +## Augment Cookie Refresh ### How It Works -CodexBar automatically refreshes Augment cookies from your browser: - -1. **Automatic Import**: On every usage refresh, CodexBar imports fresh cookies from your browser -2. **Browser Priority**: Chrome → Arc → Safari → Firefox → Brave (configurable) -3. **Session Detection**: Looks for Auth0/NextAuth session cookies -4. **Fallback**: If import fails, uses last known good cookies from keychain +CodexBar checks Augment through the provider fetch pipeline. Auto mode tries the Augment CLI first, then the +browser-cookie web path. The web path reuses cached cookies when possible and imports from supported browsers when +the cache is missing or rejected. ### Refresh Frequency - Default: Every 5 minutes (configurable in Preferences → General) -- Minimum: 30 seconds -- Cookie import happens automatically on each refresh +- Minimum: 1 minute +- Cookie import happens automatically when cached cookies need refresh ### Supported Browsers -- Chrome -- Arc -- Safari -- Firefox -- Brave -- Edge +- Safari, Chrome variants, Edge variants, Brave, Arc variants, Dia, and Firefox. ### Manual Cookie Override If automatic import fails: @@ -102,20 +99,18 @@ CodexBar/ ## Common Tasks ### Add a New Provider -1. Create `Sources/CodexBar/Providers/YourProvider/` -2. Implement `ProviderImplementation` protocol -3. Add to `ProviderRegistry.swift` -4. Add icon to `Resources/ProviderIcon-yourprovider.svg` +1. Add a `UsageProvider` case in `Sources/CodexBarCore/Providers/Providers.swift` +2. Add core descriptor/fetcher wiring under `Sources/CodexBarCore/Providers/YourProvider/` +3. Add app-side implementation under `Sources/CodexBar/Providers/YourProvider/` +4. Register the implementation in `ProviderImplementationRegistry` +5. Add icon assets such as `Resources/ProviderIcon-yourprovider.svg` ### Debug Cookie Issues -```bash -# Enable verbose logging -export CODEXBAR_LOG_LEVEL=debug -./Scripts/compile_and_run.sh - -# Check logs in Console.app -# Filter: subsystem:com.steipete.codexbar category:augment-cookie -``` +1. Enable Debug → Logging → "Enable file logging" or raise verbosity in the app settings. +2. Reproduce with `./Scripts/compile_and_run.sh`. +3. Check logs in Console.app: + - Filter: `subsystem:com.steipete.codexbar category:augment` + - Importer messages include the `[augment-cookie]` prefix ### Run Tests Only ```bash @@ -133,13 +128,13 @@ swiftlint --strict ### Local Development Build ```bash ./Scripts/package_app.sh -# Creates: CodexBar.app (ad-hoc signed) +# Creates: CodexBar.app (Developer ID by default; set CODEXBAR_SIGNING=adhoc for ad-hoc signing) ``` ### Release Build (Notarized) ```bash ./Scripts/sign-and-notarize.sh -# Creates: CodexBar-arm64.zip (notarized for distribution) +# Creates: CodexBar-.zip and CodexBar-.dSYM.zip ``` See `docs/RELEASING.md` for full release process. @@ -162,11 +157,11 @@ defaults read com.steipete.codexbar KeychainMigrationV1Completed # Should output: 1 # Check migration logs -log show --predicate 'category == "KeychainMigration"' --last 5m +log show --predicate 'category == "keychain-migration"' --last 5m ``` ### Cookies Not Refreshing -1. Check browser is supported (Chrome, Arc, Safari, Firefox, Brave) +1. Check the browser is supported by the Augment provider metadata 2. Verify you're logged into Augment in that browser 3. Check Preferences → Providers → Augment → Cookie source is "Automatic" 4. Enable debug logging and check Console.app @@ -181,12 +176,13 @@ log show --predicate 'category == "KeychainMigration"' --last 5m ### Cookie Management - Automatic browser import via SweetCookieKit -- Keychain storage for persistence +- Keychain cache for some imported browser cookies and OAuth/device-flow credentials +- `~/.codexbar/config.json` for provider settings, manual cookies, and stored API keys - Manual override for debugging -- Auto-refresh on every usage poll +- Browser-cookie import when cached sessions need refresh ### Usage Polling - Background timer (configurable frequency) - Parallel provider fetches -- Exponential backoff on errors -- Widget snapshot for iOS widget +- First failure can be suppressed when prior data exists +- WidgetKit snapshot for macOS widgets diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index e10f151a1..3c96f05ef 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -80,6 +80,9 @@ This is OS/keychain ACL behavior, not a `ThisDeviceOnly` migration issue. - Browser-imported Claude session cookies are cached in keychain service `com.steipete.codexbar.cache`. - Account key is `cookie.claude`. - Cache writes use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. +- Users can clear browser-cookie cache entries from **Preferences → Debug → Caches** or with + `codexbar cache clear --cookies`. `--provider ` scopes cookie clearing to one provider and includes scoped + Codex managed-account cookie keys. ## What still uses `ThisDeviceOnly` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0fdf6585f..bf0442c06 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -45,7 +45,7 @@ What it does: - Packages `CodexBar.app` with Info.plist and Icon.icns - Embeds Sparkle.framework, Updater, Autoupdate, XPCs - Codesigns **everything** with runtime + timestamp (deep) and adds rpath -- Zips to `CodexBar-.zip` +- Zips to `CodexBar-macos-universal-.zip` - Submits to notarytool, waits, staples, validates Gotchas fixed: @@ -58,7 +58,7 @@ Gotchas fixed: After notarization: ``` SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-priv.key \ -./Scripts/make_appcast.sh CodexBar-0.1.0.zip \ +./Scripts/make_appcast.sh CodexBar-macos-universal-0.1.0.zip \ https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml Generates HTML release notes from `CHANGELOG.md` (via `Scripts/changelog-to-html.sh`) and embeds them into the appcast entry. ``` @@ -76,7 +76,7 @@ git tag v CodexBar ships a Homebrew **Cask** in `../homebrew-tap`. When installed via Homebrew, CodexBar disables Sparkle and the app must be updated via `brew`. -After publishing the GitHub release, update the tap cask + Linux CLI formula (see `docs/releasing-homebrew.md`). +After publishing the GitHub release, update the tap cask + CLI formula (see `docs/releasing-homebrew.md`). CLI tarballs are built by `.github/workflows/release-cli.yml` after the GitHub release is published. That workflow uploads `CodexBarCLI-v-{macos-arm64,macos-x86_64,linux-aarch64,linux-x86_64}.tar.gz` plus checksums, then dispatches the Homebrew tap formula update. If the final dispatch is rate-limited, the tarballs may still be present; rerun or manually update the tap formula from the published assets. ## Checklist (quick) - [ ] Read both this file and `~/Projects/agent-scripts/docs/RELEASING-MAC.md`; resolve any conflicts toward CodexBar’s specifics. @@ -87,12 +87,14 @@ After publishing the GitHub release, update the tap cask + Linux CLI formula (se - [ ] Generate Sparkle appcast with private key - Sparkle ed25519 private key path: `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-KEEP-SECURE.txt` (primary) and `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle-VibeTunnel/sparkle-private-key-KEEP-SECURE.txt` (older backup) - Upload the dSYM archive alongside the app zip on the GitHub release; the release script now automates this and will fail if it’s missing. - - After publishing the release, run `Scripts/check-release-assets.sh ` to confirm both the app zip and dSYM zip are present on GitHub. - - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` + - After publishing the release and the Release CLI workflow finishes, run `Scripts/check-release-assets.sh ` to confirm the app zip, dSYM zip, CLI tarballs, and CLI checksums are present on GitHub. + - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-macos-universal-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` - Beta channel: prefix the command with `SPARKLE_CHANNEL=beta` to tag the entry. - Verify the enclosure signature + size: `SPARKLE_PRIVATE_KEY_FILE=... ./Scripts/verify_appcast.sh ` - [ ] Upload zip + appcast to feed; publish tag + GitHub release so Sparkle URL is live (avoid 404) -- [ ] Homebrew tap: update `../homebrew-tap/Casks/codexbar.rb` (url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (Linux CLI tarball urls + sha256), then verify: +- [ ] Homebrew tap: update `../homebrew-tap/Casks/codexbar.rb` (url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (CLI tarball urls + sha256), then verify: + - `gh run watch --exit-status` + - `Scripts/check-release-assets.sh v` - `brew uninstall --cask codexbar || true` - `brew untap steipete/tap || true; brew tap steipete/tap` - `brew install --cask steipete/tap/codexbar && open -a CodexBar` @@ -100,7 +102,7 @@ After publishing the GitHub release, update the tap cask + Linux CLI formula (se - [ ] Changelog sanity: single top-level title, no duplicate version sections, versions strictly descending with no repeats - [ ] Release pages: title format `CodexBar `, notes as Markdown list (no stray blank lines) - [ ] Changelog/release notes are user-facing: avoid internal-only bullets (build numbers, script bumps) and keep entries concise -- [ ] Download uploaded `CodexBar-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) +- [ ] Download uploaded `CodexBar-macos-universal-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) - [ ] Confirm `appcast.xml` points to the new zip/version and renders the HTML release notes (not escaped tags) - [ ] Verify on GitHub Releases: assets present (zip, appcast), release notes match changelog, version/tag correct - [ ] Open the appcast URL in browser to confirm the new entry is visible and enclosure URL is reachable diff --git a/docs/alibaba-coding-plan.md b/docs/alibaba-coding-plan.md index 339ba88c4..480204803 100644 --- a/docs/alibaba-coding-plan.md +++ b/docs/alibaba-coding-plan.md @@ -19,7 +19,10 @@ When the RPC endpoint returns `ConsoleNeedLogin`, CodexBar treats that as a cons ## Token sources (fallback order) 1) Config token (`~/.codexbar/config.json` -> `providers[].apiKey` for provider `alibaba`). -2) Environment variable `ALIBABA_CODING_PLAN_API_KEY`. +2) Environment variables, checked in order: + - `ALIBABA_CODING_PLAN_API_KEY` + - `ALIBABA_QWEN_API_KEY` + - `DASHSCOPE_API_KEY` ## Region + endpoint behavior - International host: `https://modelstudio.console.alibabacloud.com` diff --git a/docs/antigravity.md b/docs/antigravity.md index ab99af30f..5ba4978da 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -1,14 +1,32 @@ --- -summary: "Antigravity provider notes: local LSP probing, port discovery, quota parsing, and UI mapping." +summary: "Antigravity provider notes: OAuth usage, multi-account switching, local LSP probing, and quota parsing." read_when: - Adding or modifying the Antigravity provider - Debugging Antigravity port detection or quota parsing - Adjusting Antigravity menu labels or model mapping + - Working with Antigravity OAuth or account switching --- # Antigravity provider -Antigravity is a local-only provider. We talk directly to the Antigravity language server running on the same machine. +Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. + +## OAuth account switching + +- Login still uses Antigravity's Google OAuth client, discovered from `Antigravity.app` or overridden with `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`. +- A successful login writes the latest shared credentials to `~/.codexbar/antigravity/oauth_creds.json` and upserts a token-account entry for the Google account. +- Each token-account entry stores serialized `AntigravityOAuthCredentials` and is injected into remote fetches through `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON`. +- When a token account is selected, the OAuth fetcher uses that account before falling back to the shared credentials file. +- The menu action is labeled `Add Account...`; switching between saved accounts uses the existing segmented/stacked token-account menu UI. + +## Remote OAuth data sources + +- `POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` +- `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` +- `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` + +## Local data sources + fallback order ## Data sources + fallback order diff --git a/docs/augment.md b/docs/augment.md index b7f5fcdc9..0790ebdd6 100644 --- a/docs/augment.md +++ b/docs/augment.md @@ -79,9 +79,9 @@ When the CLI is unavailable or not authenticated, CodexBar falls back to browser The provider includes an automatic session keepalive system: -- **Check Interval**: Every 5 minutes +- **Check Interval**: Every 1 minute - **Refresh Buffer**: Refreshes 5 minutes before cookie expiration -- **Rate Limiting**: Minimum 2 minutes between refresh attempts +- **Rate Limiting**: Minimum 1 minute between refresh attempts - **Session Cookies**: Refreshed every 30 minutes (no expiration date) This ensures your session stays active without manual intervention. @@ -170,7 +170,7 @@ This prevents cookies from other subdomains being sent to the API. ### Session Refresh Mechanism -1. Keepalive checks cookie expiration every 5 minutes +1. Keepalive checks cookie expiration every 1 minute 2. If expiration is within 5 minutes, triggers refresh 3. Pings `/api/auth/session` to trigger cookie update 4. Waits 1 second for browser to update cookies diff --git a/docs/bedrock.md b/docs/bedrock.md new file mode 100644 index 000000000..7a8237f6d --- /dev/null +++ b/docs/bedrock.md @@ -0,0 +1,71 @@ +--- +summary: "AWS Bedrock provider: Cost Explorer credentials, budget tracking, and usage display." +read_when: + - Setting up AWS Bedrock usage tracking + - Debugging Bedrock Cost Explorer fetches + - Updating Bedrock credentials, region, or budget handling +--- + +# AWS Bedrock provider + +CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. + +## Setup + +Provide AWS credentials through the environment inherited by CodexBar or the CLI: + +```bash +export AWS_ACCESS_KEY_ID="..." +export AWS_SECRET_ACCESS_KEY="..." +export AWS_REGION="us-east-1" +``` + +Optional: + +```bash +export AWS_SESSION_TOKEN="..." +export CODEXBAR_BEDROCK_BUDGET="250" +``` + +The AWS identity must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. + +## Data source + +- Service: AWS Cost Explorer. +- Region: `AWS_REGION` or `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. +- Usage: current-month Bedrock spend and historical daily cost buckets. +- Budget: `CODEXBAR_BEDROCK_BUDGET`, when set to a positive dollar amount. +- Test override: `CODEXBAR_BEDROCK_API_URL` replaces the Cost Explorer endpoint. + +## Display + +- Shows month-to-date Bedrock spend. +- Shows budget progress when a budget is configured. +- Reuses the shared inline dashboard for daily cost history when enough buckets are available. + +## CLI + +```bash +codexbar --provider bedrock --source api +codexbar --provider bedrock --format json --pretty +``` + +## Troubleshooting + +### "No AWS Bedrock cost data available" + +- Confirm the credentials are visible to CodexBar. +- Confirm the AWS account has Cost Explorer enabled. +- Confirm the IAM principal can call `ce:GetCostAndUsage`. +- If using temporary credentials, include `AWS_SESSION_TOKEN`. + +### Wrong region + +Set `AWS_REGION` or `AWS_DEFAULT_REGION`. Bedrock usage is regional, but Cost Explorer itself is account-level; CodexBar still needs a signing region for the request. + +## Key files + +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift` diff --git a/docs/claude.md b/docs/claude.md index dba35bac2..b1b726c02 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -14,9 +14,13 @@ automatic selection, but the codebase still has multiple active Claude `.auto` d pending. For the exact current-state parity contract, see [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). +When an Anthropic Admin API key is configured, Claude can also show organization-level spend/messages/tokens in the +same inline dashboard pattern used by the OpenAI API provider. + ## Data sources + selection order ### Default selection (debug menu disabled) +- If an Admin API key is configured, the Admin API strategy is used for Claude API spend/usage. - App runtime main pipeline: OAuth API → CLI PTY → Web API. - CLI runtime main pipeline: Web API → CLI PTY. - Explicit picker modes (OAuth/Web/CLI) bypass automatic fallback. @@ -26,6 +30,22 @@ pending. For the exact current-state parity contract, see Usage source picker: - Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI). +Admin API key setup: +- Preferences → Providers → Claude → Admin API key, stored in `~/.codexbar/config.json`. +- CLI/env: `printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin`. +- Token accounts can also hold `sk-ant-admin...` keys; they route to the Admin API instead of cookie/OAuth usage. +- Environment fallback: `ANTHROPIC_ADMIN_KEY`. + +## Admin API +- Key prefix: `sk-ant-admin...`. +- Endpoints: + - `/v1/organizations/cost_report` + - `/v1/organizations/usage_report/messages` +- Output: + - Today/7d/30d spend and message/token summaries. + - Inline 30-day dashboard chart when daily buckets are present. + - Identity login method: `Admin API`. + ## Keychain prompt policy (Claude OAuth) - Preferences → Providers → Claude → Keychain prompt policy. - Options: @@ -42,8 +62,9 @@ Usage source picker: ## OAuth API (preferred) - Credentials: - - Keychain service: `Claude Code-credentials` (primary on macOS). + - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. + - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). - Endpoint: - `GET https://api.anthropic.com/api/oauth/usage` @@ -52,10 +73,12 @@ Usage source picker: - `anthropic-beta: oauth-2025-04-20` - Mapping: - `five_hour` → session window. - - `seven_day` → weekly 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. - `extra_usage` → Extra usage cost (monthly spend/limit). -- Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise. +- 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 + Max/Pro/Team/Enterprise. ## Web API (cookies) - Preferences → Providers → Claude → Cookie source (Automatic or Manual). diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md new file mode 100644 index 000000000..3dbb16058 --- /dev/null +++ b/docs/cli-configuration.md @@ -0,0 +1,85 @@ +--- +summary: "CodexBar CLI configuration commands for provider toggles, API keys, and isolated config files." +read_when: + - Using codexbar config from scripts or CI + - Enabling or disabling providers without opening Settings + - Storing provider API keys from the command line +--- + +# CLI configuration + +`codexbar config` edits the same `~/.codexbar/config.json` file used by the app's Settings → Providers pane. +The CLI writes the file with `0600` permissions. + +## Providers + +List persistent provider toggles: + +```bash +codexbar config providers +codexbar config providers --json --pretty +``` + +Enable or disable a provider: + +```bash +codexbar config enable --provider grok +codexbar config disable --provider cursor +``` + +These are persistent app/CLI settings. They are different from `codexbar usage --provider grok`, which is a one-shot +command override and does not edit config. + +If every provider is disabled, `codexbar usage` with no `--provider` prints no text output, and +`codexbar usage --json` prints `[]`. Passing `--provider ` still fetches that provider for the one command. + +## API keys + +API keys are stored under the provider entry in config: + +```bash +printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin +``` + +`set-api-key` enables the provider by default. Add `--no-enable` when you only want to save the key: + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codexbar config set-api-key --provider openrouter --stdin --no-enable +``` + +Useful examples: + +```bash +printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai --stdin +printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin +printf '%s' "$DEEPGRAM_API_KEY" | codexbar config set-api-key --provider deepgram --stdin +printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin +``` + +Only providers that consume config-backed API keys accept this command. Admin API providers may require a key with +organization/usage permissions, not a normal inference key. Browser/OAuth providers such as Grok use their own provider +sessions instead of an xAI API key for CodexBar's billing view, so enable them with +`codexbar config enable --provider grok`. + +## Isolated config files + +For tests, demos, and CI, point CodexBar at a temporary config file: + +```bash +export CODEXBAR_CONFIG=/tmp/codexbar-config.json +codexbar config enable --provider grok +codexbar config providers --json --pretty +``` + +The override applies to both reads and writes for the current process environment. + +## Validation + +After hand-editing config: + +```bash +codexbar config validate +codexbar config dump --pretty +``` + +`dump` prints normalized config, including providers omitted from a hand-written file. diff --git a/docs/cli.md b/docs/cli.md index a13c8f830..cf654c79a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,21 +8,23 @@ read_when: # CodexBar CLI -A lightweight Commander-based CLI that mirrors the menubar app’s data paths (Codex web/RPC → PTY fallback; Claude web by default with CLI fallback and OAuth debug). +A lightweight Commander-based CLI that mirrors the menu bar app’s provider fetchers and config file. Use it when you need usage numbers in scripts, CI, or dashboards without UI. ## Install - In the app: **Preferences → Advanced → Install CLI**. This symlinks `CodexBarCLI` to `/usr/local/bin/codexbar` and `/opt/homebrew/bin/codexbar`. -- From the repo: `./bin/install-codexbar-cli.sh` (same symlink targets). +- From the repo, after installing `CodexBar.app` in `/Applications`: `./bin/install-codexbar-cli.sh` (same symlink targets). - Manual: `ln -sf "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI" /usr/local/bin/codexbar`. -### Linux install -- Homebrew (Linuxbrew, Linux only): `brew install steipete/tap/codexbar`. -- Download `CodexBarCLI-v-linux-.tar.gz` from GitHub Releases (x86_64 + aarch64). -- Extract; run `./codexbar` (symlink) or `./CodexBarCLI`. +### Release tarball install (macOS/Linux) +- Homebrew formula (Linux today): `brew install steipete/tap/codexbar`. +- Download release tarballs from GitHub Releases: + - macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` + - Linux: `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` +- Extract and run `./codexbar` (symlink) or `./CodexBarCLI`. ``` -tar -xzf CodexBarCLI-v0.17.0-linux-x86_64.tar.gz +tar -xzf CodexBarCLI-v0.17.0-macos-x86_64.tar.gz ./codexbar --version ./codexbar usage --format json --pretty ``` @@ -39,29 +41,42 @@ See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage (Claude + Codex) without web/CLI access. +- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. - `--format text|json` (default: text). - `--refresh` ignores cached scans. +- `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. + - 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=`. +- `codexbar cache clear` clears local CodexBar caches. + - `--cookies` removes cached browser-cookie headers from the CodexBar Keychain cache. + - `--cookies --provider ` removes browser-cookie cache entries for that provider, including managed Codex account scopes. + - `--cost` removes local cost-usage scan caches. + - `--all` clears both cookies and cost caches. `--provider` is cookie-only and cannot be combined with `--cost` or `--all`. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). + - With three or more providers enabled, the default stays scoped to enabled providers; use `--provider all` to query + every registered provider. - `--account