diff --git a/.sisyphus/drafts/multi-account-codex-review.md b/.sisyphus/drafts/multi-account-codex-review.md new file mode 100644 index 000000000000..73ff18c5204c --- /dev/null +++ b/.sisyphus/drafts/multi-account-codex-review.md @@ -0,0 +1,66 @@ +# Draft: Multi-account OAuth Codex Review + +## Requirements (confirmed) + +- User wants thorough analysis/review of implemented multi-account support for OAuth Codex. +- User wants detailed review of account rotation implementation. +- User wants detailed review of quota status checks. +- User wants optimization recommendations aligned with best practices. +- User wants iterative process with thorough validation. + +## Technical Decisions + +- Start with exhaustive context gathering in parallel (explore + librarian + direct code search). +- Treat this as refactoring/optimization planning, not immediate implementation. +- Treat this as high-risk reliability review (auth persistence + request-path rotation). + +## Research Findings + +- Implementation hotspots identified: + - `packages/opencode/src/auth/index.ts` (multi-account schema, active index, rate-limit state, usage persistence) + - `packages/opencode/src/plugin/codex.ts` (OAuth flow, Codex fetch interception, 429 detection, switch/retry, usage harvesting) + - `packages/opencode/src/cli/cmd/auth.ts` (login/list/switch/usage workflows) + - `packages/opencode/src/server/routes/provider.ts` (`/codex/usage` endpoint, concurrent usage fetches) +- Current behavior observed: + - Selection path uses `getActiveCodexAccount()` for requests and `getNextAvailableCodexAccount()` on rate limit. + - Rotation currently selects first non-rate-limited account; no runtime-configurable round-robin found in code path. + - Usage persisted from both `/wham/usage` fetch and response headers. + - Default reset fallback is 5h when limit reset parse fails. +- High-risk findings (initial): + - Login still allows fallback email `"unknown"` in OAuth result handling. + - `setCodexAccount()` dedupes by `email`/`accountId`; placeholder emails can collapse distinct accounts. + - `auth list` uses non-null assertion on `rateLimit.resetAt` while schema permits missing `resetAt`. + - Persistence is read-modify-write on shared `auth.json`; concurrent updates can race. + - `/codex/usage` does parallel per-account fetch + write, which can amplify race windows. + - Device OAuth polling loop can run indefinitely without timeout/cancellation in headless flow. + - Recursive retry on 429 in `codexFetch()` can amplify storms under concurrent failures. +- Test coverage snapshot (initial): + - `packages/opencode/test/plugin/codex.test.ts` focuses on JWT/account-id extraction helpers. + - No direct tests found yet for rotation selection, fairness mode, 429 retry loops, or concurrent usage update safety. + - No direct integration test found for `/provider/codex/usage` mixed success/failure semantics. +- External best-practice anchors collected: + - Token lifecycle discipline (short-lived access, robust refresh handling, stable account IDs). + - Strict rate-limit header honoring (`Retry-After`, reset windows), per-account throttling/queues. + - Exponential backoff + jitter for 429 handling. + - Strong telemetry on quota windows/fairness. + +## Test Strategy Decision + +- **Infrastructure exists**: YES (`packages/opencode` Bun tests). +- **Automated tests**: [DECISION NEEDED] +- **Automated tests**: YES (TDD) ✅ +- **Agent-Executed QA**: ALWAYS (mandatory regardless of test choice). + +## Open Questions + +- Exact scope boundary for "fix them": planning-only vs immediate execution after plan handoff. +- Acceptable behavior changes vs strict behavior preservation. +- Required validation strategy preference (TDD/tests-after/no-unit-tests + agent QA). +- Rotation policy target: resolved -> keep first-available default, fairness opt-in. +- Quota unknown policy: resolved -> fail-open. +- Retry budget: resolved -> max retries=2 with jittered exponential backoff. + +## Scope Boundaries + +- INCLUDE: multi-account OAuth Codex, rotation flow, quota status checks, robustness/performance/maintainability risks. +- EXCLUDE: unrelated provider/account systems unless directly coupled. diff --git a/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md b/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md new file mode 100644 index 000000000000..57c3cc6beb1a --- /dev/null +++ b/.sisyphus/plans/multi-account-codex-rotation-quota-optimization.md @@ -0,0 +1,460 @@ +# Multi-account OAuth Codex Rotation + Quota Optimization + +## TL;DR + +> **Quick Summary**: Harden existing multi-account Codex OAuth by making rotation deterministic and safe under concurrency, improving quota freshness semantics, and preventing retry/account-state corruption. +> +> **Deliverables**: +> +> - Deterministic, configurable rotation state machine with safe defaults +> - Concurrency-safe auth persistence updates for multi-account state +> - Robust 429/retry behavior with bounded retries + backoff/jitter +> - Consistent quota freshness surfaces across plugin/CLI/server +> - TDD coverage for rotation/quota/concurrency edge paths +> +> **Estimated Effort**: Large +> **Parallel Execution**: YES - 3 waves +> **Critical Path**: Task 1 -> Task 2 -> Task 3 -> Task 4 -> Task 6 + +--- + +## Context + +### Original Request + +Thorough and detailed analysis/review of implemented multi-account support for OAuth Codex rotation and quota status checks; identify problems and optimize according to best practices. + +### Interview Summary + +**Key Discussions**: + +- Scope fixed to existing implementation, not greenfield rewrite. +- Strategy fixed to TDD. +- Focus fixed to reliability/correctness/perf/maintainability of rotation + quota path. + +**Research Findings**: + +- Core files: `packages/opencode/src/auth/index.ts`, `packages/opencode/src/plugin/codex.ts`, `packages/opencode/src/cli/cmd/auth.ts`, `packages/opencode/src/server/routes/provider.ts`. +- High-risk issues: shared `auth.json` race windows, recursive retry storm potential, placeholder-email identity collapse, weak tests on rotation/quota state machine. + +### Metis Review + +**Identified Gaps** (addressed in plan): + +- Missing explicit policy contracts (rotation mode, stale quota semantics, retry budget). +- Missing persistence guardrails (atomic/conflict-safe writes). +- Missing acceptance tests for concurrency and failover paths. + +--- + +## Work Objectives + +### Core Objective + +Make multi-account Codex selection/rotation/quota behavior deterministic, concurrency-safe, observable, and test-backed without broad architectural rewrite. + +### Concrete Deliverables + +- Rotation policy contract + implementation with default compatibility. +- Safe multi-writer persistence strategy for `auth.json` updates. +- Hardened retry/429 handling and account cooldown logic. +- Unified quota freshness/status contract across plugin, CLI, and provider route. +- New tests validating race, failover, and quota semantics. + +### Definition of Done + +- [ ] Targeted suites pass under TDD for auth rotation/quota behavior. +- [ ] No unbounded recursion/retry path remains in Codex request flow. +- [ ] Concurrent state updates do not lose `activeIndex`, account metadata, or usage/rate-limit fields. +- [ ] CLI list/usage and `/provider/codex/usage` show consistent freshness/error semantics. + +### Must Have + +- Preserve current external behavior by default unless policy flag explicitly changes it. +- Keep implementation scope confined to Codex multi-account path. +- Add deterministic tests for edge conditions and concurrency. + +### Must NOT Have (Guardrails) + +- No storage-backend rewrite beyond safe file-update strategy. +- No unrelated provider refactors. +- No manual-only verification steps. +- No silent account merges on placeholder identities. + +--- + +## Verification Strategy (MANDATORY) + +> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION** +> +> All verification is agent-executable. + +### Test Decision + +- **Infrastructure exists**: YES +- **Automated tests**: TDD +- **Framework**: `bun test` + +### If TDD Enabled + +Each task follows RED-GREEN-REFACTOR and includes command-level pass criteria. + +### Agent-Executed QA Scenarios (MANDATORY — ALL tasks) + +Use Bash with deterministic command assertions for module/integration behavior; include negative/failure scenarios for each task. + +--- + +## Execution Strategy + +### Parallel Execution Waves + +Wave 1 (Start Immediately): + +- Task 1 (state-machine spec + failing tests) +- Task 5 (observability contract/tests scaffolding) + +Wave 2 (After Wave 1): + +- Task 2 (persistence safety) +- Task 3 (retry/backoff/cooldown hardening) + +Wave 3 (After Wave 2): + +- Task 4 (rotation policy + identity hardening) +- Task 6 (quota freshness consistency + endpoint/CLI alignment) + +Wave 4 (After Wave 3): + +- Task 7 (integration polish + regression matrix) + +Critical Path: 1 -> 2 -> 3 -> 4 -> 6 -> 7 + +### Dependency Matrix + +| Task | Depends On | Blocks | Can Parallelize With | +| ---- | ---------- | ------- | -------------------- | +| 1 | None | 2,3,4,6 | 5 | +| 2 | 1 | 3,4,6,7 | 3 | +| 3 | 1,2 | 4,7 | 2 | +| 4 | 1,2,3 | 6,7 | None | +| 5 | None | 7 | 1 | +| 6 | 1,2,4 | 7 | None | +| 7 | 2,3,4,5,6 | None | None | + +--- + +## TODOs + +- [ ] 1. Define Rotation/Quota State Machine + RED Tests + + **What to do**: + - Write formal behavior contract for account states (active, rate-limited, stale-quota, invalid-token). + - Add failing tests for selection, rate-limit expiry, stale quota semantics, and identity handling. + + **Must NOT do**: + - No implementation changes before failing tests exist. + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: `git-master` + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 1 (with Task 5) + - **Blocks**: 2,3,4,6 + - **Blocked By**: None + + **References**: + - `packages/opencode/src/auth/index.ts` - selection and persisted state mutation methods. + - `packages/opencode/src/plugin/codex.ts` - runtime retry/429 and usage update behavior. + - `packages/opencode/test/plugin/codex.test.ts` - existing test patterns. + + **Acceptance Criteria**: + - [ ] RED tests added for rotation + quota state machine and initially fail. + - [ ] `bun test packages/opencode/test/auth/codex-rotation.test.ts` -> FAIL (expected pre-implementation). + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: RED rotation tests fail first + Tool: Bash + Preconditions: test file exists, implementation unchanged + Steps: + 1. Run: bun test packages/opencode/test/auth/codex-rotation.test.ts + 2. Assert: exit code != 0 + 3. Assert: output includes failing rotation expectation + Expected Result: test suite fails with known assertions + Evidence: terminal output capture + ``` + +- [ ] 2. Make Auth Persistence Conflict-Safe + + **What to do**: + - Implement atomic/serialized update path for shared `auth.json` mutations. + - Ensure concurrent calls do not lose account/usage/rate-limit fields. + + **Must NOT do**: + - No backend migration away from file storage in this iteration. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with Task 3) + - **Blocks**: 3,4,6,7 + - **Blocked By**: 1 + + **References**: + - `packages/opencode/src/auth/index.ts` + - `packages/opencode/src/global/index.ts` + + **Acceptance Criteria**: + - [ ] Concurrency tests pass for simultaneous mutation paths. + - [ ] `bun test packages/opencode/test/auth/codex-concurrency.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Concurrent writes preserve activeIndex and usage + Tool: Bash + Steps: + 1. Run targeted concurrency suite + 2. Assert: no lost update failures + 3. Assert: deterministic final auth state assertions pass + Expected Result: stable pass under repeated runs + Evidence: terminal output capture + ``` + +- [ ] 3. Harden 429 Retry/Backoff and Cooldown Behavior + + **What to do**: + - Replace unbounded recursive retry with bounded iterative retry budget. + - Honor reset/retry headers when available; apply jittered backoff. + + **Must NOT do**: + - No infinite retry; no stormy immediate retries. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with Task 2) + - **Blocks**: 4,7 + - **Blocked By**: 1,2 + + **References**: + - `packages/opencode/src/plugin/codex.ts` + + **Acceptance Criteria**: + - [ ] Retry budget enforced. + - [ ] Backoff/jitter tests pass for 429 sequences. + - [ ] `bun test packages/opencode/test/plugin/codex-retry.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: 429 sequence stops at retry cap + Tool: Bash + Steps: + 1. Run retry suite with mocked 429 responses + 2. Assert: retries <= configured cap + 3. Assert: failure path reports exhausted accounts safely + Expected Result: bounded retries, no recursion overflow + Evidence: test output capture + ``` + +- [ ] 4. Rotation Policy + Account Identity Hardening + + **What to do**: + - Implement policy gate for configurable rotation mode while keeping default compatibility. + - Enforce canonical identity strategy; prevent placeholder-based merges. + + **Must NOT do**: + - No default behavior flip without explicit config. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 3 + - **Blocks**: 6,7 + - **Blocked By**: 1,2,3 + + **References**: + - `packages/opencode/src/auth/index.ts` + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/cli/cmd/auth.ts` + - `packages/opencode/src/config/config.ts` + + **Acceptance Criteria**: + - [ ] Rotation mode behavior matches contract tests. + - [ ] Identity merge safety tests pass with missing/placeholder email inputs. + - [ ] `bun test packages/opencode/test/auth/codex-rotation-policy.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Placeholder email does not merge distinct accounts + Tool: Bash + Steps: + 1. Run identity safety tests + 2. Assert: separate accounts remain separate with stable IDs + Expected Result: no accidental account collapse + Evidence: test output capture + ``` + +- [ ] 5. Add Structured Telemetry for Rotation/Quota Decisions + + **What to do**: + - Add structured events/counters for selection, failover, all-accounts-limited, stale quota. + - Add tests for emitted event shape in key branches. + + **Must NOT do**: + - No noisy per-token logging flood. + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 1 (with Task 1) + - **Blocks**: 7 + - **Blocked By**: None + + **References**: + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/util/log.ts` + + **Acceptance Criteria**: + - [ ] Telemetry tests verify structured payloads on failover/exhaustion branches. + - [ ] `bun test packages/opencode/test/plugin/codex-observability.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Failover emits structured event + Tool: Bash + Steps: + 1. Run observability suite + 2. Assert: event includes account id/email hash, reason, retry count + Expected Result: deterministic telemetry shape + Evidence: test output capture + ``` + +- [ ] 6. Unify Quota Freshness Contract Across Plugin/CLI/Server + + **What to do**: + - Define and enforce freshness states (`fresh|stale|unknown`) and TTL semantics. + - Align `/provider/codex/usage` and CLI rendering to avoid unsafe assumptions on missing fields. + + **Must NOT do**: + - No misleading quota status when data is stale/unknown. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 3 + - **Blocks**: 7 + - **Blocked By**: 1,2,4 + + **References**: + - `packages/opencode/src/plugin/codex.ts` + - `packages/opencode/src/server/routes/provider.ts` + - `packages/opencode/src/cli/cmd/auth.ts` + + **Acceptance Criteria**: + - [ ] Freshness semantics test-backed end-to-end. + - [ ] `bun test packages/opencode/test/server/provider-codex-usage.test.ts` -> PASS. + - [ ] `bun test packages/opencode/test/cli/auth-codex-list-usage.test.ts` -> PASS. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Missing resetAt handled safely in list output + Tool: Bash + Steps: + 1. Run CLI usage/list tests with missing reset fields + 2. Assert: no crash, status shown as unknown/stale contract + Expected Result: resilient rendering + Evidence: test output capture + ``` + +- [ ] 7. Final Regression Matrix + Stability Verification + + **What to do**: + - Run focused suites together and verify no regression in existing Codex behavior. + - Confirm config-compat default behavior remains preserved. + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 4 + - **Blocks**: None + - **Blocked By**: 2,3,4,5,6 + + **References**: + - `packages/opencode/test/plugin/codex.test.ts` + - New test files from Tasks 1-6 + + **Acceptance Criteria**: + - [ ] All targeted suites pass. + - [ ] Existing plugin codex tests still pass. + - [ ] No flaky failures across repeat runs. + + **Agent-Executed QA Scenarios**: + + ```bash + Scenario: Full targeted codex reliability matrix + Tool: Bash + Steps: + 1. Run all targeted codex/auth/server/cli suites + 2. Repeat run at least twice + 3. Assert: consistent pass, no intermittent failures + Expected Result: stable regression baseline + Evidence: terminal output capture + ``` + +--- + +## Commit Strategy + +| After Task | Message | Verification | +| ---------- | ---------------------------------------------------------------------- | ---------------------------- | +| 1 | `test(codex): add failing rotation state-machine coverage` | target test file fails (RED) | +| 2-3 | `fix(codex): harden auth persistence and retry failover` | targeted suites pass | +| 4-6 | `feat(codex): enforce rotation identity and quota freshness contracts` | targeted suites pass | +| 7 | `test(codex): add regression matrix for multi-account quota flow` | matrix pass | + +--- + +## Success Criteria + +### Verification Commands + +```bash +bun test packages/opencode/test/auth/codex-rotation.test.ts +bun test packages/opencode/test/auth/codex-concurrency.test.ts +bun test packages/opencode/test/plugin/codex-retry.test.ts +bun test packages/opencode/test/plugin/codex-observability.test.ts +bun test packages/opencode/test/server/provider-codex-usage.test.ts +bun test packages/opencode/test/cli/auth-codex-list-usage.test.ts +bun test packages/opencode/test/plugin/codex.test.ts +``` + +### Final Checklist + +- [ ] Rotation logic deterministic under configured policy. +- [ ] Shared auth persistence safe under concurrent updates. +- [ ] Retry behavior bounded and header-aware. +- [ ] Quota freshness/status consistent across plugin/CLI/server. +- [ ] Regression matrix green and stable. + +--- + +## Defaults Applied + +- Preserve current default behavior (`first available`) unless explicit config changes mode. +- Keep scope constrained to Codex multi-account path and directly coupled files. + +## Decisions Needed + +- None. User selected recommended defaults: + - Rotation default: keep `first_available`, fairness mode opt-in. + - Quota unknown policy: fail-open. + - Retry budget: max retries per request = 2, jittered exponential backoff. + +--- + +## Unresolved Questions + +- none. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..2685ec3cdcfc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +```bash +# Core development +bun install # install dependencies +bun dev # run opencode TUI (in packages/opencode dir) +bun dev # run against specific directory +bun dev . # run in repo root + +# Type checking +bun turbo typecheck # typecheck all packages + +# Building +./packages/opencode/script/build.ts --single # build standalone executable + +# Web app (packages/app) +bun run --cwd packages/app dev # dev server at localhost:5173 + +# Desktop app (packages/desktop) - requires Tauri/Rust +bun run --cwd packages/desktop tauri dev # native app + dev server +bun run --cwd packages/desktop tauri build # production build + +# SDK regeneration (after API changes) +./packages/sdk/js/script/build.ts # regenerate JS SDK +./script/generate.ts # regenerate SDK and related files +``` + +## Architecture + +**Monorepo** using Bun workspaces with Turbo for task orchestration. + +**Key packages:** +- `packages/opencode` - Core CLI, server, and TUI (main business logic) +- `packages/app` - Web UI components (SolidJS) +- `packages/desktop` - Native desktop app (Tauri v2 wrapping app) +- `packages/console` - Cloud SaaS (Cloudflare Workers, PlanetScale) +- `packages/sdk` - TypeScript SDK (@opencode-ai/sdk) +- `packages/plugin` - Plugin system (@opencode-ai/plugin) + +**Client-Server model:** +- HTTP server (`packages/opencode/src/server/server.ts`) built on Hono +- TUI client (`packages/opencode/src/cli/cmd/tui/`) using SolidJS + OpenTUI +- Sessions, tools, and LLM requests flow through the server + +**Agent system** (`packages/opencode/src/agent/`): +- `build` - default agent with full access +- `plan` - read-only for analysis/exploration +- `general` - subagent for complex searches (invoke with @general) + +**Tool system** (`packages/opencode/src/tool/`): 40+ tools (bash, edit, read, grep, glob, write, lsp, etc.) with permission checks and plugin extensibility. + +**Provider system** (`packages/opencode/src/provider/`): 20+ LLM providers via ai SDK (Claude, OpenAI, Google, Bedrock, etc.) + +## Code Style + +- Prefer `const` over `let`; use ternary or early returns to avoid mutation +- Avoid `else` statements; use early returns +- Avoid unnecessary destructuring; prefer `obj.a` over `const { a } = obj` for context +- Prefer `.catch()` over try/catch +- Single-word variable names when descriptive enough +- Use Bun APIs (e.g., `Bun.file()`) when available +- Avoid `any` type + +## Git/PR Guidelines + +- Default branch: `dev` +- All PRs must reference an existing issue (`Fixes #123` or `Closes #123`) +- Conventional commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:` +- Optional scope: `feat(app):`, `fix(desktop):` +- Keep PRs small and focused; no AI-generated walls of text +- UI changes need screenshots/videos + +## Important Notes + +- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE +- After API/SDK changes in server.ts, run `./script/generate.ts` +- Tests run from individual packages, not root (`bun run test` from root will fail) diff --git a/github/AGENTS.md b/github/AGENTS.md new file mode 100644 index 000000000000..2e80654aae8e --- /dev/null +++ b/github/AGENTS.md @@ -0,0 +1,24 @@ +# GITHUB ACTION KNOWLEDGE BASE + +## OVERVIEW + +GitHub Action runtime that handles `/oc` and `/opencode` comment workflows. + +## WHERE TO LOOK + +- Action runtime entry: `github/index.ts` +- Package manifest: `github/package.json` +- Usage and local mock flow: `github/README.md` +- Action metadata: `github/action.yml` + +## CONVENTIONS + +- Module entry is `index.ts` (`type: module`). +- Depends on `@actions/*`, Octokit, and `@opencode-ai/sdk`. +- Local testing uses mock env vars (`MOCK_EVENT`, `MOCK_TOKEN`, `GITHUB_RUN_ID`, model/api keys). + +## ANTI-PATTERNS + +- Don’t hardcode provider secrets/tokens. +- Don’t change event parsing without checking issue + review-comment paths. +- Don’t diverge from documented local mock workflow when debugging action logic. diff --git a/packages/console/AGENTS.md b/packages/console/AGENTS.md new file mode 100644 index 000000000000..022f455570d7 --- /dev/null +++ b/packages/console/AGENTS.md @@ -0,0 +1,26 @@ +# CONSOLE KNOWLEDGE BASE + +## OVERVIEW + +`packages/console` is the SaaS domain split into `app`, `core`, `function`, `mail`, `resource`. + +## WHERE TO LOOK + +- UI routes/pages: `packages/console/app/src/routes` +- Billing/data logic: `packages/console/core/src` +- Worker handlers: `packages/console/function/src` +- Email templates: `packages/console/mail/emails` +- Env/resource wiring: `packages/console/resource` + +## CONVENTIONS + +- `console/app` build chains `generate-sitemap.ts`, `vite build`, then `../../opencode/script/schema.ts`. +- `console/core` uses `sst shell` scripts for db/model promotion flows. +- `console/resource` has target-specific entries (`resource.node.ts`, `resource.cloudflare.ts`). +- Typecheck uses `tsgo --noEmit` in console packages. + +## ANTI-PATTERNS + +- Don’t treat `console` as one app; each subpackage has distinct runtime/deploy needs. +- Don’t bypass `sst shell` for core db/model scripts. +- Don’t place business logic inside email/template files. diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md new file mode 100644 index 000000000000..a6967e57d45d --- /dev/null +++ b/packages/desktop/AGENTS.md @@ -0,0 +1,25 @@ +# DESKTOP KNOWLEDGE BASE + +## OVERVIEW + +`packages/desktop` is Tauri v2 shell + Vite frontend wrapping shared app/ui packages. + +## WHERE TO LOOK + +- Frontend entry: `packages/desktop/src/index.tsx` +- Native backend: `packages/desktop/src-tauri/src/lib.rs` +- Tauri config: `packages/desktop/src-tauri/tauri.conf.json` +- Build preparation: `packages/desktop/scripts/prepare.ts` + +## CONVENTIONS + +- Local web-only dev: `bun run --cwd packages/desktop dev`. +- Native dev/build: `bun run --cwd packages/desktop tauri dev|build`. +- `tauri.conf.json` runs `beforeDevCommand` and `beforeBuildCommand` via Bun scripts. +- Release pipeline expects Rust toolchain and signing secrets. + +## ANTI-PATTERNS + +- Don’t treat desktop as web-only package; Rust/Tauri paths matter. +- Don’t edit generated release artifacts in workflow outputs. +- Don’t change bundle targets/icons casually; CI/release depends on them. diff --git a/packages/enterprise/AGENTS.md b/packages/enterprise/AGENTS.md new file mode 100644 index 000000000000..3e3382cfd6e2 --- /dev/null +++ b/packages/enterprise/AGENTS.md @@ -0,0 +1,24 @@ +# ENTERPRISE KNOWLEDGE BASE + +## OVERVIEW + +Enterprise-facing Solid/Vite app with API routes and Cloudflare-target build variant. + +## WHERE TO LOOK + +- App routes/pages: `packages/enterprise/src/routes` +- Core logic/services: `packages/enterprise/src/core` +- Build targets: `packages/enterprise/package.json` +- Vite/runtime config: `packages/enterprise/vite.config.ts` + +## CONVENTIONS + +- Default build is `vite build`; Cloudflare build uses `OPENCODE_DEPLOYMENT_TARGET=cloudflare`. +- Typecheck uses `tsgo --noEmit`. +- Depends on shared `@opencode-ai/ui` and `@opencode-ai/util`. + +## ANTI-PATTERNS + +- Don’t treat enterprise routes as identical to `packages/app` routes. +- Don’t bypass target env flag when building for Cloudflare. +- Don’t duplicate shared ui/util code locally. diff --git a/packages/opencode/IMPLEMENTATION_PLAN.md b/packages/opencode/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000000..4ba2b31beab0 --- /dev/null +++ b/packages/opencode/IMPLEMENTATION_PLAN.md @@ -0,0 +1,32 @@ +title: Rotation plan +description: Steps for email checks and account switching + +--- + +## Define config + +Add `experimental.codex.rotation` with `first` (default) and `round-robin` in `src/config/config.ts`. Keep it relaxed and obvious in config docs or inline descriptions. + +--- + +## Validate email + +Require a non-empty email in OAuth results and fail the login if missing in `src/cli/cmd/auth.ts` and `src/plugin/codex.ts`. Also guard `Auth.setCodexAccount` to reject empty emails and avoid overwriting accounts with `unknown` values. + +--- + +## Handle limits + +Persist `rateLimit` metadata until `resetAt` passes, and only clear on expiry in `src/auth/index.ts`. Make `auth list` tolerant of missing `resetAt` when rendering status in `src/cli/cmd/auth.ts`. + +--- + +## Add round robin + +Extend `Auth.getNextAvailableCodexAccount` with a mode argument and store the next index after selection. Use config in `src/plugin/codex.ts` to choose `first` or `round-robin` when retrying after a 429. + +--- + +## Verify + +Manually login two accounts and confirm both emails render in `auth list`. Trigger a 429 and confirm automatic switch plus correct strategy by toggling config. diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac80..18c1d56ba9b4 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -31,19 +31,78 @@ export namespace Auth { }) .meta({ ref: "WellKnownAuth" }) - export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) + // Multi-account support for Codex OAuth + export const CodexAccountRateLimit = z.object({ + limited: z.boolean(), + resetAt: z.number().optional(), + lastError: z.string().optional(), + }) + + export const CodexUsageWindow = z.object({ + usedPercent: z.number(), + windowMinutes: z.number(), + resetAt: z.number(), + }) + export type CodexUsageWindow = z.infer + + export const CodexAccountUsage = z.object({ + planType: z.string().optional(), + primary: CodexUsageWindow.optional(), + secondary: CodexUsageWindow.optional(), + credits: z + .object({ + hasCredits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().optional(), + }) + .optional(), + fetchedAt: z.number(), + }) + export type CodexAccountUsage = z.infer + + export const CodexAccount = z.object({ + id: z.string(), + email: z.string(), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + rateLimit: CodexAccountRateLimit.optional(), + usage: CodexAccountUsage.optional(), + }) + export type CodexAccount = z.infer + + export const CodexMultiAccount = z + .object({ + type: z.literal("codex-multi"), + accounts: z.array(CodexAccount), + activeIndex: z.number().default(0), + }) + .meta({ ref: "CodexMultiAccount" }) + export type CodexMultiAccount = z.infer + + export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown, CodexMultiAccount]).meta({ ref: "Auth" }) export type Info = z.infer const filepath = path.join(Global.Path.data, "auth.json") + async function readRaw(): Promise> { + const file = Bun.file(filepath) + return file.json().catch(() => ({}) as Record) + } + + async function writeRaw(data: Record): Promise { + const file = Bun.file(filepath) + await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + } + export async function get(providerID: string) { const auth = await all() return auth[providerID] } export async function all(): Promise> { - const file = Bun.file(filepath) - const data = await file.json().catch(() => ({}) as Record) + const data = await readRaw() return Object.entries(data).reduce( (acc, [key, value]) => { const parsed = Info.safeParse(value) @@ -56,15 +115,311 @@ export namespace Auth { } export async function set(key: string, info: Info) { - const file = Bun.file(filepath) - const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 }) + const data = await readRaw() + data[key] = info + await writeRaw(data) } export async function remove(key: string) { - const file = Bun.file(filepath) - const data = await all() + const data = await readRaw() delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) + await writeRaw(data) + } + + // ===== Codex Multi-Account Methods ===== + + export async function getCodexAuth(): Promise { + const { auth } = await readCodexAuthAndData() + return auth + } + + async function normalizeCodexAuth( + auth: CodexMultiAccount, + data?: Record, + ): Promise { + let mutated = false + const now = Date.now() + + if (auth.accounts.length === 0) { + if (auth.activeIndex !== 0) { + auth.activeIndex = 0 + mutated = true + } + } else { + const clamped = Math.max(0, Math.min(auth.activeIndex, auth.accounts.length - 1)) + if (clamped !== auth.activeIndex) { + auth.activeIndex = clamped + mutated = true + } + } + + for (const account of auth.accounts) { + if (account.rateLimit?.limited && account.rateLimit.resetAt && account.rateLimit.resetAt <= now) { + account.rateLimit = undefined + mutated = true + } + } + + if (mutated) { + const next = data ?? (await readRaw()) + next["codex"] = auth + await writeRaw(next) + } + + return auth + } + + async function migrateCodexAuth( + legacy: z.infer, + data: Record, + sourceKey?: "codex" | "openai", + ): Promise { + const migrated: CodexMultiAccount = { + type: "codex-multi", + accounts: [ + { + id: crypto.randomUUID(), + email: legacy.accountId || "account-1", + refresh: legacy.refresh, + access: legacy.access, + expires: legacy.expires, + accountId: legacy.accountId, + }, + ], + activeIndex: 0, + } + data["codex"] = migrated + if (sourceKey && sourceKey !== "codex") delete data[sourceKey] + await writeRaw(data) + return migrated + } + + async function readCodexAuthAndData(): Promise<{ data: Record; auth?: CodexMultiAccount }> { + const data = await readRaw() + const codex = data["codex"] + if (codex) { + const parsed = CodexMultiAccount.safeParse(codex) + if (parsed.success) { + const auth = await normalizeCodexAuth(parsed.data, data) + return { data, auth } + } + + // Check for legacy single-account format and migrate + const legacy = Oauth.safeParse(codex) + if (legacy.success) { + const auth = await migrateCodexAuth(legacy.data, data, "codex") + return { data, auth } + } + + return { data } + } + + // Migrate legacy OpenAI OAuth (single account) into Codex multi-account + const legacyOpenAI = Oauth.safeParse(data["openai"]) + if (legacyOpenAI.success) { + const auth = await migrateCodexAuth(legacyOpenAI.data, data, "openai") + return { data, auth } + } + + return { data } + } + + export async function getCodexAccounts(): Promise { + const auth = await getCodexAuth() + return auth?.accounts ?? [] + } + + export async function getActiveCodexAccount(): Promise { + const auth = await getCodexAuth() + if (!auth || auth.accounts.length === 0) return undefined + const index = Math.min(Math.max(auth.activeIndex, 0), auth.accounts.length - 1) + return auth.accounts[index] + } + + export async function setCodexAccount( + account: Omit & { id?: string }, + ): Promise { + const { data, auth: existing } = await readCodexAuthAndData() + const auth = existing ?? { type: "codex-multi", accounts: [], activeIndex: 0 } + + // Check for existing account with same email or accountId (update instead of duplicate) + const existingIndex = auth.accounts.findIndex( + (a) => a.email === account.email || (!!account.accountId && a.accountId === account.accountId), + ) + const newAccount: CodexAccount = { + id: account.id || crypto.randomUUID(), + email: account.email, + refresh: account.refresh, + access: account.access, + expires: account.expires, + accountId: account.accountId, + } + + if (existingIndex >= 0) { + // Update existing account tokens + auth.accounts[existingIndex] = { + ...auth.accounts[existingIndex], + ...newAccount, + id: auth.accounts[existingIndex].id, + } + auth.activeIndex = existingIndex + } else { + // Add new account + auth.accounts.push(newAccount) + auth.activeIndex = auth.accounts.length - 1 + } + + data["codex"] = auth + await writeRaw(data) + } + + export async function removeCodexAccount(id: string): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const index = auth.accounts.findIndex((a) => a.id === id) + if (index < 0) return + + auth.accounts.splice(index, 1) + + // Adjust activeIndex if needed + if (auth.accounts.length === 0) { + auth.activeIndex = 0 + } else if (auth.activeIndex >= auth.accounts.length) { + auth.activeIndex = auth.accounts.length - 1 + } else if (index < auth.activeIndex) { + auth.activeIndex-- + } + + if (auth.accounts.length === 0) { + delete data["codex"] + } else { + data["codex"] = auth + } + await writeRaw(data) + } + + export async function setActiveCodexIndex(index: number): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth || auth.accounts.length === 0) return + + auth.activeIndex = Math.max(0, Math.min(index, auth.accounts.length - 1)) + data["codex"] = auth + await writeRaw(data) + } + + export async function markCodexAccountRateLimited(id: string, resetAt?: number): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const account = auth.accounts.find((a) => a.id === id) + if (!account) return + + account.rateLimit = { + limited: true, + resetAt: resetAt ?? Date.now() + 5 * 60 * 60 * 1000, // default 5 hours + } + + data["codex"] = auth + await writeRaw(data) + } + + export async function clearCodexAccountRateLimit(id: string): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const account = auth.accounts.find((a) => a.id === id) + if (!account) return + + account.rateLimit = undefined + data["codex"] = auth + await writeRaw(data) + } + + export async function getNextAvailableCodexAccount(): Promise<{ account: CodexAccount; index: number } | undefined> { + const { data, auth } = await readCodexAuthAndData() + if (!auth || auth.accounts.length === 0) return undefined + + const now = Date.now() + let mutated = false + + // Clear expired rate limits + for (const account of auth.accounts) { + if (account.rateLimit?.limited && account.rateLimit.resetAt && account.rateLimit.resetAt <= now) { + account.rateLimit = undefined + mutated = true + } + } + + // Find first available account (not rate limited) + for (let i = 0; i < auth.accounts.length; i++) { + const account = auth.accounts[i] + if (!account.rateLimit?.limited) { + if (i !== auth.activeIndex) { + auth.activeIndex = i + mutated = true + } + if (mutated) { + data["codex"] = auth + await writeRaw(data) + } + return { account, index: i } + } + } + + if (mutated) { + data["codex"] = auth + await writeRaw(data) + } + + return undefined + } + + export async function updateCodexAccountTokens( + id: string, + tokens: { access: string; refresh: string; expires: number; accountId?: string }, + ): Promise { + const { data, auth } = await readCodexAuthAndData() + + if (!auth) { + const legacy = Oauth.safeParse(data["openai"]) + if (!legacy.success) return + data["openai"] = { + ...legacy.data, + access: tokens.access, + refresh: tokens.refresh, + expires: tokens.expires, + ...(tokens.accountId ? { accountId: tokens.accountId } : {}), + } + await writeRaw(data) + return + } + + const account = + auth.accounts.find((a) => a.id === id) ?? + (id === "legacy" && auth.accounts.length === 1 ? auth.accounts[0] : undefined) + if (!account) return + + account.access = tokens.access + account.refresh = tokens.refresh + account.expires = tokens.expires + if (tokens.accountId) account.accountId = tokens.accountId + + data["codex"] = auth + await writeRaw(data) + } + + export async function updateCodexAccountUsage(id: string, usage: CodexAccountUsage): Promise { + const { data, auth } = await readCodexAuthAndData() + if (!auth) return + + const account = + auth.accounts.find((a) => a.id === id) ?? + (id === "legacy" && auth.accounts.length === 1 ? auth.accounts[0] : undefined) + if (!account) return + + account.usage = usage + data["codex"] = auth + await writeRaw(data) } } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c16..1bfb8878cea1 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -11,6 +11,7 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" +import { fetchCodexUsage } from "../../plugin/codex" type PluginAuth = NonNullable @@ -35,6 +36,29 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } const method = plugin.auth.methods[index] + // Check for existing Codex accounts if this is an OpenAI OAuth login + const isCodexOAuth = provider === "openai" && method.type === "oauth" + let shouldReplaceAll = false + + if (isCodexOAuth) { + const existingAccounts = await Auth.getCodexAccounts() + if (existingAccounts.length > 0) { + const emails = existingAccounts.map((a) => a.email).join(", ") + const action = await prompts.select({ + message: `Found ${existingAccounts.length} existing ChatGPT account(s): ${emails}`, + options: [ + { label: "Add new account", value: "add" }, + { label: "Replace all accounts", value: "replace" }, + ], + }) + if (prompts.isCancel(action)) throw new UI.CancelledError() + shouldReplaceAll = action === "replace" + if (shouldReplaceAll) { + await Auth.remove("codex") + } + } + } + // Handle prompts for all auth types await Bun.sleep(10) const inputs: Record = {} @@ -81,7 +105,19 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + + // Special handling for Codex multi-account + if (isCodexOAuth && "refresh" in result) { + const email = (result as any).email || (result as any).accountId || "unknown" + await Auth.setCodexAccount({ + email, + refresh: result.refresh, + access: result.access, + expires: result.expires, + accountId: (result as any).accountId, + }) + spinner.stop(`Login successful (${email})`) + } else if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", @@ -90,14 +126,14 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): expires, ...extraFields, }) - } - if ("key" in result) { + spinner.stop("Login successful") + } else if ("key" in result) { await Auth.set(saveProvider, { type: "api", key: result.key, }) + spinner.stop("Login successful") } - spinner.stop("Login successful") } } @@ -113,7 +149,19 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } if (result.type === "success") { const saveProvider = result.provider ?? provider - if ("refresh" in result) { + + // Special handling for Codex multi-account + if (isCodexOAuth && "refresh" in result) { + const email = (result as any).email || (result as any).accountId || "unknown" + await Auth.setCodexAccount({ + email, + refresh: result.refresh, + access: result.access, + expires: result.expires, + accountId: (result as any).accountId, + }) + prompts.log.success(`Login successful (${email})`) + } else if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result await Auth.set(saveProvider, { type: "oauth", @@ -122,14 +170,14 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): expires, ...extraFields, }) - } - if ("key" in result) { + prompts.log.success("Login successful") + } else if ("key" in result) { await Auth.set(saveProvider, { type: "api", key: result.key, }) + prompts.log.success("Login successful") } - prompts.log.success("Login successful") } } @@ -159,11 +207,141 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): return false } +export const AuthSwitchCommand = cmd({ + command: "switch", + describe: "switch active OpenAI OAuth account", + async handler() { + UI.empty() + prompts.intro("Switch account") + + const codexAccounts = await Auth.getCodexAccounts() + if (codexAccounts.length === 0) { + prompts.log.error("No ChatGPT accounts found") + prompts.outro("Done") + return + } + + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + const selected = await prompts.select({ + message: "Select active ChatGPT account", + options: codexAccounts.map((account, index) => { + const isActive = index === activeIndex + const status = account.rateLimit?.limited ? " [rate limited]" : "" + return { + label: `ChatGPT (${account.email})${status}` + (isActive ? " *" : ""), + value: index.toString(), + } + }), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + const nextIndex = Number.parseInt(selected, 10) + if (!Number.isNaN(nextIndex)) { + await Auth.setActiveCodexIndex(nextIndex) + } + + prompts.outro("Active account updated") + }, +}) + +function formatResetTime(resetAt: number): string { + const diff = resetAt - Date.now() + if (diff <= 0) return "now" + const hours = Math.floor(diff / 3600000) + const mins = Math.floor((diff % 3600000) / 60000) + if (hours > 24) return `${Math.floor(hours / 24)}d` + if (hours > 0) return `${hours}h ${mins}m` + return `${mins}m` +} + +function formatUsageBar(usedPercent: number, width = 10): string { + const remaining = 100 - usedPercent + const filled = Math.round((remaining / 100) * width) + return "█".repeat(filled) + "░".repeat(width - filled) +} + +export const AuthUsageCommand = cmd({ + command: "usage", + describe: "show Codex usage status for all accounts", + async handler() { + UI.empty() + prompts.intro("Codex Usage") + + const accounts = await Auth.getCodexAccounts() + if (accounts.length === 0) { + prompts.log.error("No ChatGPT accounts found") + prompts.outro("Done") + return + } + + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + for (let i = 0; i < accounts.length; i++) { + const account = accounts[i] + const isActive = i === activeIndex + const activeMarker = isActive ? " *" : "" + + prompts.log.info(`${account.email}${activeMarker}`) + + const spinner = prompts.spinner() + spinner.start("Fetching usage...") + + try { + const usage = await fetchCodexUsage(account) + spinner.stop("") + + const planLabel = usage.planType ? ` (${usage.planType})` : "" + prompts.log.info(` Plan: ${usage.planType || "unknown"}${planLabel}`) + + if (usage.primary) { + const remaining = 100 - usage.primary.usedPercent + const bar = formatUsageBar(usage.primary.usedPercent) + const reset = formatResetTime(usage.primary.resetAt) + prompts.log.info(` 5h limit: [${bar}] ${remaining}% left, resets in ${reset}`) + } + + if (usage.secondary) { + const remaining = 100 - usage.secondary.usedPercent + const bar = formatUsageBar(usage.secondary.usedPercent) + const reset = formatResetTime(usage.secondary.resetAt) + prompts.log.info(` Weekly: [${bar}] ${remaining}% left, resets in ${reset}`) + } + + if (usage.credits) { + if (usage.credits.unlimited) { + prompts.log.info(" Credits: unlimited") + } else if (usage.credits.balance) { + prompts.log.info(` Credits: $${usage.credits.balance}`) + } + } + } catch (err) { + spinner.stop("Failed to fetch usage", 1) + prompts.log.error(` Error: ${err instanceof Error ? err.message : String(err)}`) + } + + if (i < accounts.length - 1) { + prompts.log.info("") + } + } + + prompts.outro(`${accounts.length} account${accounts.length === 1 ? "" : "s"}`) + }, +}) + export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs + .command(AuthLoginCommand) + .command(AuthLogoutCommand) + .command(AuthListCommand) + .command(AuthSwitchCommand) + .command(AuthUsageCommand) + .demandCommand(), async handler() {}, }) @@ -177,15 +355,38 @@ export const AuthListCommand = cmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const codexAccounts = await Auth.getCodexAccounts() const results = Object.entries(await Auth.all()) const database = await ModelsDev.get() + let count = 0 for (const [providerID, result] of results) { + // Skip codex multi-account - we'll show individual accounts + if (providerID === "codex" && result.type === "codex-multi") continue + const name = database[providerID]?.name || providerID prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + count++ + } + + // Show individual Codex accounts + if (codexAccounts.length > 0) { + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + for (let i = 0; i < codexAccounts.length; i++) { + const account = codexAccounts[i] + const isActive = i === activeIndex + const status = account.rateLimit?.limited + ? ` [rate limited until ${new Date(account.rateLimit.resetAt!).toLocaleTimeString()}]` + : "" + const activeMarker = isActive ? " *" : "" + prompts.log.info(`ChatGPT (${account.email})${activeMarker}${status} ${UI.Style.TEXT_DIM}oauth`) + count++ + } } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${count} credential${count === 1 ? "" : "s"}`) // Environment variables section const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -379,22 +580,56 @@ export const AuthLogoutCommand = cmd({ describe: "log out from a configured provider", async handler() { UI.empty() - const credentials = await Auth.all().then((x) => Object.entries(x)) prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } + + // Build options list with special handling for Codex multi-account + const codexAccounts = await Auth.getCodexAccounts() const database = await ModelsDev.get() - const providerID = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ + const credentials = await Auth.all() + + type CredentialOption = { label: string; value: string; isCodexAccount?: boolean; accountId?: string } + const options: CredentialOption[] = [] + + for (const [key, value] of Object.entries(credentials)) { + // Skip codex entry - we'll list individual accounts instead + if (key === "codex" && value.type === "codex-multi") continue + + options.push({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, - })), + }) + } + + // Add individual Codex accounts + for (const account of codexAccounts) { + const status = account.rateLimit?.limited ? " [rate limited]" : "" + options.push({ + label: `ChatGPT (${account.email})${status}` + UI.Style.TEXT_DIM + " (oauth)", + value: `codex:${account.id}`, + isCodexAccount: true, + accountId: account.id, + }) + } + + if (options.length === 0) { + prompts.log.error("No credentials found") + return + } + + const selected = await prompts.select({ + message: "Select credential to remove", + options: options.map((o) => ({ label: o.label, value: o.value })), }) - if (prompts.isCancel(providerID)) throw new UI.CancelledError() - await Auth.remove(providerID) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + + // Handle Codex account removal + if (selected.startsWith("codex:")) { + const accountId = selected.replace("codex:", "") + await Auth.removeCodexAccount(accountId) + } else { + await Auth.remove(selected) + } + prompts.outro("Logout successful") }, }) diff --git a/packages/opencode/src/cli/cmd/codex.ts b/packages/opencode/src/cli/cmd/codex.ts new file mode 100644 index 000000000000..a0fc2f9de63e --- /dev/null +++ b/packages/opencode/src/cli/cmd/codex.ts @@ -0,0 +1,72 @@ +import path from "path" +import { cmd } from "./cmd" +import { UI } from "../ui" +import { Config } from "../../config/config" +import { Instance } from "../../project/instance" +import { BunProc } from "../../bun" +import { Filesystem } from "../../util/filesystem" +import { fileURLToPath } from "url" + +async function resolvePluginPath(entry: string): Promise { + if (entry.startsWith("file://")) { + return fileURLToPath(entry) + } + const lastAt = entry.lastIndexOf("@") + const pkg = lastAt > 0 ? entry.substring(0, lastAt) : entry + const version = lastAt > 0 ? entry.substring(lastAt + 1) : "latest" + return BunProc.install(pkg, version) +} + +async function resolveCodexLoginBin(): Promise { + const config = await Config.get() + const plugins = config.plugin ?? [] + const match = plugins.find((item) => item.includes("opencode-codex-auth-plugin")) + if (!match) return null + + const root = await resolvePluginPath(match) + const candidates = [ + path.join(root, "bin", "opencode-codex-login.js"), + path.join(root, "dist", "bin.js"), + ] + + for (const candidate of candidates) { + if (await Filesystem.exists(candidate)) return candidate + } + return null +} + +const CodexLoginCommand = cmd({ + command: "login", + describe: "log in to Codex multi-account", + async handler() { + await Instance.provide({ + directory: process.cwd(), + async fn() { + const bin = await resolveCodexLoginBin() + if (!bin) { + UI.error("opencode-codex-auth-plugin not found in config. Add it to opencode.json first.") + process.exitCode = 1 + return + } + const proc = Bun.spawn({ + cmd: [process.execPath, bin], + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }) + const exit = await proc.exited + if (exit !== 0) { + UI.error("Codex login failed") + process.exitCode = 1 + } + }, + }) + }, +}) + +export const CodexCommand = cmd({ + command: "codex", + describe: "codex auth utilities", + builder: (yargs) => yargs.command(CodexLoginCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index f3cd54db6e4a..9b3c10b88fe5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -8,6 +8,22 @@ import { Installation } from "@/installation" export type DialogStatusProps = {} +function formatResetTime(resetAt: number): string { + const diff = resetAt - Date.now() + if (diff <= 0) return "now" + const hours = Math.floor(diff / 3600000) + const mins = Math.floor((diff % 3600000) / 60000) + if (hours > 24) return `${Math.floor(hours / 24)}d` + if (hours > 0) return `${hours}h ${mins}m` + return `${mins}m` +} + +function usageBar(usedPercent: number, width = 10): string { + const remaining = 100 - usedPercent + const filled = Math.round((remaining / 100) * width) + return "█".repeat(filled) + "░".repeat(width - filled) +} + export function DialogStatus() { const sync = useSync() const { theme } = useTheme() @@ -16,6 +32,8 @@ export function DialogStatus() { const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) + const codexAccounts = createMemo(() => sync.data.codex_usage?.accounts ?? []) + const plugins = createMemo(() => { const list = sync.data.config.plugin ?? [] const result = list.map((value) => { @@ -59,6 +77,62 @@ export function DialogStatus() { OpenCode v{Installation.VERSION} + 0}> + + {codexAccounts().length} Codex Accounts + + {(account) => { + const isRateLimited = account.usage?.primary && account.usage.primary.usedPercent >= 100 + return ( + + + + {account.isActive ? "●" : "•"} + + + {account.email}{" "} + + {account.usage?.planType ?? "unknown"} + {isRateLimited && [rate limited]} + + + + + {(primary) => { + const remaining = 100 - primary().usedPercent + return ( + + {" "}5h: [{usageBar(primary().usedPercent)}] {remaining}% left, resets in{" "} + {formatResetTime(primary().resetAt)} + + ) + }} + + + {(secondary) => { + const remaining = 100 - secondary().usedPercent + return ( + + {" "}Weekly: [{usageBar(secondary().usedPercent)}] {remaining}% left, resets in{" "} + {formatResetTime(secondary().resetAt)} + + ) + }} + + + {" "}Error: {account.error} + + + ) + }} + + + 0} fallback={No MCP Servers}> {Object.keys(sync.data.mcp).length} MCP Servers diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bbad..f7a65443de58 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -17,6 +17,7 @@ import type { ProviderListResponse, ProviderAuthMethod, VcsInfo, + ProviderCodexUsageResponse, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" @@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path + codex_usage: ProviderCodexUsageResponse | undefined }>({ provider_next: { all: [], @@ -100,6 +102,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, + codex_usage: undefined, }) const sdk = useSDK() @@ -395,6 +398,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), + sdk.client.provider.codex.usage().then((x) => setStore("codex_usage", reconcile(x.data))), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 8ace2fff3725..4fa7008b6615 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -17,6 +17,18 @@ export function Footer() { if (route.data.type !== "session") return [] return sync.data.permission[route.data.sessionID] ?? [] }) + + // Check if any Codex account is approaching limit (>90% used) + const codexLimitWarning = createMemo(() => { + const accounts = sync.data.codex_usage?.accounts ?? [] + for (const account of accounts) { + if (account.usage?.primary && account.usage.primary.usedPercent >= 90) { + const remaining = 100 - account.usage.primary.usedPercent + return { email: account.email, remaining } + } + } + return null + }) const directory = useDirectory() const connected = useConnected() @@ -60,6 +72,13 @@ export function Footer() { + + {(warning) => ( + + ! Codex {warning().remaining}% left + + )} + 0}> {permissions().length} Permission diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6dc5e99e91ef..8487aba196b6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -26,6 +26,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { CodexCommand } from "./cli/cmd/codex" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -97,6 +98,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(CodexCommand) .fail((msg, err) => { if ( msg?.startsWith("Unknown argument") || diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c8b833baeca3..4f657aa4556a 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -2,6 +2,8 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" import { Installation } from "../installation" import { Auth, OAUTH_DUMMY_KEY } from "../auth" +import { Bus } from "../bus" +import { TuiEvent } from "../cli/cmd/tui/event" import os from "os" import { ProviderTransform } from "@/provider/transform" @@ -10,6 +12,7 @@ const log = Log.create({ service: "plugin.codex" }) const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" +const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 @@ -85,6 +88,46 @@ export function extractAccountId(tokens: TokenResponse): string | undefined { return undefined } +export function extractEmail(tokens: TokenResponse): string | undefined { + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token) + if (claims?.email) return claims.email + } + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token) + if (claims?.email) return claims.email + } + return undefined +} + +function isCodexRateLimitError(response: Response, body?: string): boolean { + if (response.status === 429) return true + if (!body) return false + const lower = body.toLowerCase() + return ( + lower.includes("5-hour message limit") || + lower.includes("weekly cap") || + lower.includes("quota exhausted") || + lower.includes("rate limit") || + lower.includes("usage limit") + ) +} + +function parseCodexResetTime(body?: string): number | undefined { + if (!body) return undefined + // Parse "try again in Xh Ym" or "try again in X hours" + const hourMatch = body.match(/try again in (\d+)\s*h/i) + if (hourMatch) { + return Date.now() + parseInt(hourMatch[1]) * 60 * 60 * 1000 + } + const minuteMatch = body.match(/try again in (\d+)\s*m/i) + if (minuteMatch) { + return Date.now() + parseInt(minuteMatch[1]) * 60 * 1000 + } + // Default to 5 hours if we can't parse + return Date.now() + 5 * 60 * 60 * 1000 +} + function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", @@ -142,6 +185,149 @@ async function refreshAccessToken(refreshToken: string): Promise return response.json() } +interface CodexUsageApiResponse { + plan_type?: string + rate_limit?: { + allowed?: boolean + limit_reached?: boolean + primary_window?: { + used_percent: number + limit_window_seconds: number + reset_after_seconds?: number + reset_at?: number + } + secondary_window?: { + used_percent: number + limit_window_seconds: number + reset_after_seconds?: number + reset_at?: number + } + } + credits?: { + has_credits?: boolean + unlimited?: boolean + balance?: string + } +} + +function parseUsageFromHeaders(headers: Headers): Auth.CodexAccountUsage | undefined { + const primaryUsed = headers.get("x-codex-primary-used-percent") + const primaryWindow = headers.get("x-codex-primary-window-minutes") + const primaryReset = headers.get("x-codex-primary-reset-at") + + if (!primaryUsed || !primaryWindow || !primaryReset) return undefined + + const secondaryUsed = headers.get("x-codex-secondary-used-percent") + const secondaryWindow = headers.get("x-codex-secondary-window-minutes") + const secondaryReset = headers.get("x-codex-secondary-reset-at") + + const hasCredits = headers.get("x-codex-credits-has-credits") + const creditsBalance = headers.get("x-codex-credits-balance") + + const usage: Auth.CodexAccountUsage = { + fetchedAt: Date.now(), + primary: { + usedPercent: parseInt(primaryUsed, 10), + windowMinutes: parseInt(primaryWindow, 10), + resetAt: parseInt(primaryReset, 10) * 1000, + }, + } + + if (secondaryUsed && secondaryWindow && secondaryReset) { + usage.secondary = { + usedPercent: parseInt(secondaryUsed, 10), + windowMinutes: parseInt(secondaryWindow, 10), + resetAt: parseInt(secondaryReset, 10) * 1000, + } + } + + if (hasCredits !== null) { + usage.credits = { + hasCredits: hasCredits === "true", + unlimited: false, + balance: creditsBalance ?? undefined, + } + } + + return usage +} + +function parseUsageFromApiResponse(response: CodexUsageApiResponse): Auth.CodexAccountUsage { + const usage: Auth.CodexAccountUsage = { + planType: response.plan_type, + fetchedAt: Date.now(), + } + + if (response.rate_limit?.primary_window) { + const pw = response.rate_limit.primary_window + usage.primary = { + usedPercent: pw.used_percent, + windowMinutes: Math.round(pw.limit_window_seconds / 60), + resetAt: pw.reset_at ? pw.reset_at * 1000 : Date.now() + (pw.reset_after_seconds ?? 0) * 1000, + } + } + + if (response.rate_limit?.secondary_window) { + const sw = response.rate_limit.secondary_window + usage.secondary = { + usedPercent: sw.used_percent, + windowMinutes: Math.round(sw.limit_window_seconds / 60), + resetAt: sw.reset_at ? sw.reset_at * 1000 : Date.now() + (sw.reset_after_seconds ?? 0) * 1000, + } + } + + if (response.credits) { + usage.credits = { + hasCredits: response.credits.has_credits ?? false, + unlimited: response.credits.unlimited ?? false, + balance: response.credits.balance, + } + } + + return usage +} + +export async function fetchCodexUsage(account: Auth.CodexAccount): Promise { + let access = account.access + let refresh = account.refresh + + // Refresh token if expired + if (account.expires < Date.now()) { + log.info("refreshing codex token for usage fetch", { email: account.email }) + const tokens = await refreshAccessToken(account.refresh) + access = tokens.access_token + refresh = tokens.refresh_token ?? account.refresh + const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const accountId = extractAccountId(tokens) || account.accountId + await Auth.updateCodexAccountTokens(account.id, { + access, + refresh, + expires: expiresAt, + accountId, + }) + } + + const headers: HeadersInit = { + Authorization: `Bearer ${access}`, + } + if (account.accountId) { + headers["ChatGPT-Account-Id"] = account.accountId + } + + const response = await fetch(CODEX_USAGE_ENDPOINT, { headers }) + if (!response.ok) { + throw new Error(`Usage fetch failed: ${response.status}`) + } + + const data: CodexUsageApiResponse = await response.json() + const usage = parseUsageFromApiResponse(data) + + // Persist usage to storage + await Auth.updateCodexAccountUsage(account.id, usage) + + return usage +} + const HTML_SUCCESS = ` @@ -354,7 +540,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { provider: "openai", async loader(getAuth, provider) { const auth = await getAuth() - if (auth.type !== "oauth") return {} + // Check for Codex multi-account first + const codexAuth = await Auth.getCodexAuth() + const hasCodexAccounts = !!codexAuth?.accounts.length + const hasLegacyOAuth = auth?.type === "oauth" + + // Support both legacy oauth and new codex-multi format + if (!hasLegacyOAuth && !hasCodexAccounts) return {} // Filter models to only allowed Codex models for OAuth const allowedModels = new Set([ @@ -412,86 +604,159 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } } - return { - apiKey: OAUTH_DUMMY_KEY, - async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { - // Remove dummy API key authorization header - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.delete("authorization") - init.headers.delete("Authorization") - } else if (Array.isArray(init.headers)) { - init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") - } else { - delete init.headers["authorization"] - delete init.headers["Authorization"] - } + const codexFetch = async (requestInput: RequestInfo | URL, init?: RequestInit): Promise => { + // Remove dummy API key authorization header + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.delete("authorization") + init.headers.delete("Authorization") + } else if (Array.isArray(init.headers)) { + init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization") + } else { + delete init.headers["authorization"] + delete init.headers["Authorization"] } + } + // Get active account from multi-account storage + let account = await Auth.getActiveCodexAccount() + if (!account) { + // Fallback to legacy single-account mode const currentAuth = await getAuth() - if (currentAuth.type !== "oauth") return fetch(requestInput, init) - - // Cast to include accountId field - const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - - // Check if token needs refresh - if (!currentAuth.access || currentAuth.expires < Date.now()) { - log.info("refreshing codex access token") - const tokens = await refreshAccessToken(currentAuth.refresh) - const newAccountId = extractAccountId(tokens) || authWithAccount.accountId - await input.client.auth.set({ - path: { id: "openai" }, - body: { - type: "oauth", - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - ...(newAccountId && { accountId: newAccountId }), - }, - }) - currentAuth.access = tokens.access_token - authWithAccount.accountId = newAccountId + if (!currentAuth || currentAuth.type !== "oauth") return fetch(requestInput, init) + account = { + id: "legacy", + email: "unknown", + refresh: currentAuth.refresh, + access: currentAuth.access, + expires: currentAuth.expires, + accountId: (currentAuth as any).accountId, } + } - // Build headers - const headers = new Headers() - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((value, key) => headers.set(key, value)) - } else if (Array.isArray(init.headers)) { - for (const [key, value] of init.headers) { - if (value !== undefined) headers.set(key, String(value)) - } - } else { - for (const [key, value] of Object.entries(init.headers)) { - if (value !== undefined) headers.set(key, String(value)) - } + // Check if token needs refresh + if (!account.access || account.expires < Date.now()) { + log.info("refreshing codex access token", { email: account.email }) + const tokens = await refreshAccessToken(account.refresh) + const newAccountId = extractAccountId(tokens) || account.accountId + const expiresAt = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const refresh = tokens.refresh_token ?? account.refresh + await Auth.updateCodexAccountTokens(account.id, { + access: tokens.access_token, + refresh, + expires: expiresAt, + accountId: newAccountId, + }) + account.access = tokens.access_token + account.refresh = refresh + account.expires = expiresAt + account.accountId = newAccountId + } + + // Build headers + const headers = new Headers() + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => headers.set(key, value)) + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + if (value !== undefined) headers.set(key, String(value)) + } + } else { + for (const [key, value] of Object.entries(init.headers)) { + if (value !== undefined) headers.set(key, String(value)) } } + } + + // Set authorization header with access token + headers.set("authorization", `Bearer ${account.access}`) - // Set authorization header with access token - headers.set("authorization", `Bearer ${currentAuth.access}`) + // Set ChatGPT-Account-Id header for organization subscriptions + if (account.accountId) { + headers.set("ChatGPT-Account-Id", account.accountId) + } + + // Rewrite URL to Codex endpoint + const parsed = + requestInput instanceof URL + ? requestInput + : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) + const url = + parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") + ? new URL(CODEX_API_ENDPOINT) + : parsed + + const response = await fetch(url, { + ...init, + headers, + }) + + // Capture usage from response headers (non-blocking) + const headerUsage = parseUsageFromHeaders(response.headers) + if (headerUsage) { + Auth.updateCodexAccountUsage(account.id, headerUsage).catch(() => {}) + } + + // Check for rate limit error and handle account switching + if (!response.ok) { + const body = await response + .clone() + .text() + .catch(() => undefined) + if (isCodexRateLimitError(response, body)) { + const resetTime = parseCodexResetTime(body) + log.info("codex rate limit hit", { email: account.email, resetTime }) + + // Mark current account as rate limited + await Auth.markCodexAccountRateLimited(account.id, resetTime) + + // Try to switch to next available account + const next = await Auth.getNextAvailableCodexAccount() + if (next) { + log.info("switching to next codex account", { email: next.account.email }) + Bus.publish(TuiEvent.ToastShow, { + variant: "warning", + message: `Rate limited. Switched to ${next.account.email}`, + }) + // Retry the request with the new account + return codexFetch(requestInput, init) + } - // Set ChatGPT-Account-Id header for organization subscriptions - if (authWithAccount.accountId) { - headers.set("ChatGPT-Account-Id", authWithAccount.accountId) + // All accounts are rate limited - notify user + const accounts = await Auth.getCodexAccounts() + if (accounts.length > 0) { + const resetTimes = accounts + .map((a) => a.rateLimit?.resetAt) + .filter((time): time is number => typeof time === "number") + if (resetTimes.length > 0) { + const nextReset = Math.min(...resetTimes) + const waitMinutes = Math.max(1, Math.ceil((nextReset - Date.now()) / 60000)) + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: `All accounts rate limited. Next available in ${waitMinutes}m`, + }) + } else { + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: "All accounts rate limited.", + }) + } + } else { + Bus.publish(TuiEvent.ToastShow, { + variant: "error", + message: "Rate limited. Please try again later.", + }) + } } + } - // Rewrite URL to Codex endpoint - const parsed = - requestInput instanceof URL - ? requestInput - : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) - const url = - parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(CODEX_API_ENDPOINT) - : parsed - - return fetch(url, { - ...init, - headers, - }) - }, + return response + } + + return { + apiKey: OAUTH_DUMMY_KEY, + fetch: codexFetch, } }, methods: [ @@ -514,12 +779,14 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { const tokens = await callbackPromise stopOAuthServer() const accountId = extractAccountId(tokens) + const email = extractEmail(tokens) return { type: "success" as const, refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, accountId, + email, } }, } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1cad3b3162a2..c8b00d02373e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -860,9 +860,16 @@ export namespace Provider { // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise let hasAuth = false + let hasCodexAccounts = false const auth = await Auth.get(providerID) if (auth) hasAuth = true + if (!hasAuth && providerID === "openai") { + const codexAuth = await Auth.getCodexAuth() + hasCodexAccounts = !!codexAuth?.accounts.length + if (hasCodexAccounts) hasAuth = true + } + // Special handling for github-copilot: also check for enterprise auth if (providerID === "github-copilot" && !hasAuth) { const enterpriseAuth = await Auth.get("github-copilot-enterprise") @@ -873,7 +880,7 @@ export namespace Provider { if (!plugin.auth.loader) continue // Load for the main provider if auth exists - if (auth) { + if (auth || hasCodexAccounts) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) const opts = options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index 872b48be79dc..98cd968df4a9 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -5,6 +5,8 @@ import { Config } from "../../config/config" import { Provider } from "../../provider/provider" import { ModelsDev } from "../../provider/models" import { ProviderAuth } from "../../provider/auth" +import { Auth } from "../../auth" +import { fetchCodexUsage } from "../../plugin/codex" import { mapValues } from "remeda" import { errors } from "../error" import { lazy } from "../../util/lazy" @@ -161,5 +163,64 @@ export const ProviderRoutes = lazy(() => }) return c.json(true) }, + ) + .get( + "/codex/usage", + describeRoute({ + summary: "Get Codex usage", + description: "Fetch usage limits for all Codex (ChatGPT) accounts.", + operationId: "provider.codex.usage", + responses: { + 200: { + description: "Codex account usage information", + content: { + "application/json": { + schema: resolver( + z.object({ + accounts: z.array( + z.object({ + id: z.string(), + email: z.string(), + isActive: z.boolean(), + usage: Auth.CodexAccountUsage.nullable(), + error: z.string().optional(), + }), + ), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const accounts = await Auth.getCodexAccounts() + const codexAuth = await Auth.getCodexAuth() + const activeIndex = codexAuth?.activeIndex ?? 0 + + const results = await Promise.all( + accounts.map(async (account, index) => { + try { + const usage = await fetchCodexUsage(account) + return { + id: account.id, + email: account.email, + isActive: index === activeIndex, + usage, + } + } catch (err) { + return { + id: account.id, + email: account.email, + isActive: index === activeIndex, + usage: account.usage ?? null, + error: err instanceof Error ? err.message : String(err), + } + } + }), + ) + + return c.json({ accounts: results }) + }, ), ) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4be6e2538f7e..cac5a7208507 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -62,7 +62,9 @@ export namespace LLM { Provider.getProvider(input.model.providerID), Auth.get(input.model.providerID), ]) - const isCodex = provider.id === "openai" && auth?.type === "oauth" + const codexAuth = provider.id === "openai" ? await Auth.getCodexAuth() : undefined + const hasCodexAccounts = provider.id === "openai" && !!codexAuth?.accounts.length + const isCodex = provider.id === "openai" && (auth?.type === "oauth" || hasCodexAccounts) const system = [] system.push( diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4cc84a5f3255..d3b6dc083d6c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -115,6 +115,7 @@ export type AuthOuathResult = { url: string; instructions: string } & ( access: string expires: number accountId?: string + email?: string } | { key: string } )) @@ -135,6 +136,7 @@ export type AuthOuathResult = { url: string; instructions: string } & ( access: string expires: number accountId?: string + email?: string } | { key: string } )) diff --git a/packages/sdk/js/AGENTS.md b/packages/sdk/js/AGENTS.md new file mode 100644 index 000000000000..14a18e41cff3 --- /dev/null +++ b/packages/sdk/js/AGENTS.md @@ -0,0 +1,25 @@ +# SDK KNOWLEDGE BASE + +## OVERVIEW + +Published JS SDK package; exports main/client/server plus `v2` surfaces and generated code. + +## WHERE TO LOOK + +- Public exports: `packages/sdk/js/package.json` +- Main entry: `packages/sdk/js/src/index.ts` +- V2 entrypoints: `packages/sdk/js/src/v2` +- Generated clients/types: `packages/sdk/js/src/gen`, `packages/sdk/js/src/v2/gen` +- Build/regeneration: `packages/sdk/js/script/build.ts` + +## CONVENTIONS + +- Build script is `./script/build.ts`; root regen also calls this. +- Treat `src/gen` and `src/v2/gen` as generated surfaces. +- Keep export map in `package.json` aligned with source entries. + +## ANTI-PATTERNS + +- Don’t hand-edit generated SDK files. +- Don’t add exports without updating the `exports` map. +- Don’t skip SDK rebuild after API/schema changes. diff --git a/packages/sdk/js/src/v2/gen/client/client.gen.ts b/packages/sdk/js/src/v2/gen/client/client.gen.ts index 627e98ec4206..47f1403429d2 100644 --- a/packages/sdk/js/src/v2/gen/client/client.gen.ts +++ b/packages/sdk/js/src/v2/gen/client/client.gen.ts @@ -162,16 +162,10 @@ export const createClient = (config: Config = {}): Client => { case "arrayBuffer": case "blob": case "formData": + case "json": case "text": data = await response[parseAs]() break - case "json": { - // Some servers return 200 with no Content-Length and empty body. - // response.json() would throw; read as text and parse if non-empty. - const text = await response.text() - data = text ? JSON.parse(text) : {} - break - } case "stream": return opts.responseStyle === "data" ? response.body @@ -250,7 +244,6 @@ export const createClient = (config: Config = {}): Client => { } return request }, - serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined, url, }) } diff --git a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts index 056a81259322..09ef3fb39360 100644 --- a/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts +++ b/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts @@ -151,8 +151,6 @@ export const createSseClient = ({ const { done, value } = await reader.read() if (done) break buffer += value - // Normalize line endings: CRLF -> LF, then CR -> LF - buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const chunks = buffer.split("\n\n") buffer = chunks.pop() ?? "" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b757b7535075..9454b93540a8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -8,17 +8,14 @@ import type { AppLogErrors, AppLogResponses, AppSkillsResponses, - Auth as Auth3, + Auth as Auth2, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, CommandListResponses, - Config as Config3, - ConfigGetResponses, + Config as Config2, ConfigProvidersResponses, - ConfigUpdateErrors, - ConfigUpdateResponses, EventSubscribeResponses, EventTuiCommandExecute, EventTuiPromptAppend, @@ -48,8 +45,6 @@ import type { McpAuthAuthenticateResponses, McpAuthCallbackErrors, McpAuthCallbackResponses, - McpAuthRemoveErrors, - McpAuthRemoveResponses, McpAuthStartErrors, McpAuthStartResponses, McpConnectResponses, @@ -74,6 +69,7 @@ import type { ProjectUpdateErrors, ProjectUpdateResponses, ProviderAuthResponses, + ProviderCodexUsageResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -238,7 +234,7 @@ export class Config extends HeyApiClient { */ public update( parameters?: { - config?: Config3 + config?: Config2 }, options?: Options, ) { @@ -254,6 +250,25 @@ export class Config extends HeyApiClient { }, }) } + + /** + * List config providers + * + * Get a list of all configured AI providers and their default models. + */ + public providers( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/config/providers", + ...options, + ...params, + }) + } } export class Global extends HeyApiClient { @@ -293,10 +308,7 @@ export class Global extends HeyApiClient { }) } - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } + config = new Config({ client: this.client }) } export class Auth extends HeyApiClient { @@ -327,7 +339,7 @@ export class Auth extends HeyApiClient { public set( parameters: { providerID: string - auth?: Auth3 + auth?: Auth2 }, options?: Options, ) { @@ -353,6 +365,105 @@ export class Auth extends HeyApiClient { }, }) } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Authenticate MCP OAuth + * + * Start OAuth flow and wait for callback (opens browser) + */ + public authenticate( + parameters: { + name: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) + } } export class Project extends HeyApiClient { @@ -643,81 +754,6 @@ export class Pty extends HeyApiClient { } } -export class Config2 extends HeyApiClient { - /** - * Get configuration - * - * Retrieve the current OpenCode configuration settings and preferences. - */ - public get( - parameters?: { - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) - return (options?.client ?? this.client).get({ - url: "/config", - ...options, - ...params, - }) - } - - /** - * Update configuration - * - * Update OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - directory?: string - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { key: "config", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List config providers - * - * Get a list of all configured AI providers and their default models. - */ - public providers( - parameters?: { - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) - return (options?.client ?? this.client).get({ - url: "/config/providers", - ...options, - ...params, - }) - } -} - export class Tool extends HeyApiClient { /** * List tool IDs @@ -919,10 +955,7 @@ export class Resource extends HeyApiClient { } export class Experimental extends HeyApiClient { - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } + resource = new Resource({ client: this.client }) } export class Session extends HeyApiClient { @@ -2116,6 +2149,27 @@ export class Oauth extends HeyApiClient { } } +export class Codex extends HeyApiClient { + /** + * Get Codex usage + * + * Fetch usage limits for all Codex (ChatGPT) accounts. + */ + public usage( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/provider/codex/usage", + ...options, + ...params, + }) + } +} + export class Provider extends HeyApiClient { /** * List providers @@ -2155,10 +2209,9 @@ export class Provider extends HeyApiClient { }) } - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } + oauth = new Oauth({ client: this.client }) + + codex = new Codex({ client: this.client }) } export class Find extends HeyApiClient { @@ -2340,137 +2393,6 @@ export class File extends HeyApiClient { } } -export class Auth2 extends HeyApiClient { - /** - * Remove MCP OAuth - * - * Remove OAuth credentials for an MCP server - */ - public remove( - parameters: { - name: string - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Start MCP OAuth - * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. - */ - public start( - parameters: { - name: string - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Complete MCP OAuth - * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. - */ - public callback( - parameters: { - name: string - directory?: string - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Authenticate MCP OAuth - * - * Start OAuth flow and wait for callback (opens browser) - */ - public authenticate( - parameters: { - name: string - directory?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, - ) - } -} - export class Mcp extends HeyApiClient { /** * Get MCP status @@ -2584,10 +2506,7 @@ export class Mcp extends HeyApiClient { }) } - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) - } + auth = new Auth({ client: this.client }) } export class Control extends HeyApiClient { @@ -2622,17 +2541,7 @@ export class Control extends HeyApiClient { }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { key: "body", map: "body" }, - ], - }, - ], - ) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }]) return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, @@ -2884,17 +2793,7 @@ export class Tui extends HeyApiClient { }, options?: Options, ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { key: "body", map: "body" }, - ], - }, - ], - ) + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }, { in: "body" }] }]) return (options?.client ?? this.client).post({ url: "/tui/publish", ...options, @@ -2942,10 +2841,7 @@ export class Tui extends HeyApiClient { }) } - private _control?: Control - get control(): Control { - return (this._control ??= new Control({ client: this.client })) - } + control = new Control({ client: this.client }) } export class Instance extends HeyApiClient { @@ -3186,128 +3082,53 @@ export class OpencodeClient extends HeyApiClient { OpencodeClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } + global = new Global({ client: this.client }) - private _auth?: Auth - get auth(): Auth { - return (this._auth ??= new Auth({ client: this.client })) - } + auth = new Auth({ client: this.client }) - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } + project = new Project({ client: this.client }) - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) - } + pty = new Pty({ client: this.client }) - private _config?: Config2 - get config(): Config2 { - return (this._config ??= new Config2({ client: this.client })) - } + config = new Config({ client: this.client }) - private _tool?: Tool - get tool(): Tool { - return (this._tool ??= new Tool({ client: this.client })) - } + tool = new Tool({ client: this.client }) - private _worktree?: Worktree - get worktree(): Worktree { - return (this._worktree ??= new Worktree({ client: this.client })) - } + worktree = new Worktree({ client: this.client }) - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } + experimental = new Experimental({ client: this.client }) - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) - } + session = new Session({ client: this.client }) - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } + part = new Part({ client: this.client }) - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } + permission = new Permission({ client: this.client }) - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } + question = new Question({ client: this.client }) - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } + provider = new Provider({ client: this.client }) - private _find?: Find - get find(): Find { - return (this._find ??= new Find({ client: this.client })) - } + find = new Find({ client: this.client }) - private _file?: File - get file(): File { - return (this._file ??= new File({ client: this.client })) - } + file = new File({ client: this.client }) - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } + mcp = new Mcp({ client: this.client }) - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } + tui = new Tui({ client: this.client }) - private _instance?: Instance - get instance(): Instance { - return (this._instance ??= new Instance({ client: this.client })) - } + instance = new Instance({ client: this.client }) - private _path?: Path - get path(): Path { - return (this._path ??= new Path({ client: this.client })) - } + path = new Path({ client: this.client }) - private _vcs?: Vcs - get vcs(): Vcs { - return (this._vcs ??= new Vcs({ client: this.client })) - } + vcs = new Vcs({ client: this.client }) - private _command?: Command - get command(): Command { - return (this._command ??= new Command({ client: this.client })) - } + command = new Command({ client: this.client }) - private _app?: App - get app(): App { - return (this._app ??= new App({ client: this.client })) - } + app = new App({ client: this.client }) - private _lsp?: Lsp - get lsp(): Lsp { - return (this._lsp ??= new Lsp({ client: this.client })) - } + lsp = new Lsp({ client: this.client }) - private _formatter?: Formatter - get formatter(): Formatter { - return (this._formatter ??= new Formatter({ client: this.client })) - } + formatter = new Formatter({ client: this.client }) - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } + event = new Event({ client: this.client }) } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9543e5b5796d..81a72d4a78cb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1876,7 +1876,44 @@ export type WellKnownAuth = { token: string } -export type Auth = OAuth | ApiAuth | WellKnownAuth +export type CodexMultiAccount = { + type: "codex-multi" + accounts: Array<{ + id: string + email: string + refresh: string + access: string + expires: number + accountId?: string + rateLimit?: { + limited: boolean + resetAt?: number + lastError?: string + } + usage?: { + planType?: string + primary?: { + usedPercent: number + windowMinutes: number + resetAt: number + } + secondary?: { + usedPercent: number + windowMinutes: number + resetAt: number + } + credits?: { + hasCredits: boolean + unlimited: boolean + balance?: string + } + fetchedAt: number + } + }> + activeIndex?: number +} + +export type Auth = OAuth | ApiAuth | WellKnownAuth | CodexMultiAccount export type NotFoundError = { name: "NotFoundError" @@ -4148,6 +4185,50 @@ export type ProviderOauthCallbackResponses = { export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type ProviderCodexUsageData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/provider/codex/usage" +} + +export type ProviderCodexUsageResponses = { + /** + * Codex account usage information + */ + 200: { + accounts: Array<{ + id: string + email: string + isActive: boolean + usage: { + planType?: string + primary?: { + usedPercent: number + windowMinutes: number + resetAt: number + } + secondary?: { + usedPercent: number + windowMinutes: number + resetAt: number + } + credits?: { + hasCredits: boolean + unlimited: boolean + balance?: string + } + fetchedAt: number + } | null + error?: string + }> + } +} + +export type ProviderCodexUsageResponse = ProviderCodexUsageResponses[keyof ProviderCodexUsageResponses] + export type FindTextData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 18022a3384d9..f69cf4e77a95 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4040,6 +4040,128 @@ ] } }, + "/provider/codex/usage": { + "get": { + "operationId": "provider.codex.usage", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get Codex usage", + "description": "Fetch usage limits for all Codex (ChatGPT) accounts.", + "responses": { + "200": { + "description": "Codex account usage information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "usage": { + "anyOf": [ + { + "type": "object", + "properties": { + "planType": { + "type": "string" + }, + "primary": { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "type": "number" + }, + "resetAt": { + "type": "number" + } + }, + "required": ["usedPercent", "windowMinutes", "resetAt"] + }, + "secondary": { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "type": "number" + }, + "resetAt": { + "type": "number" + } + }, + "required": ["usedPercent", "windowMinutes", "resetAt"] + }, + "credits": { + "type": "object", + "properties": { + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + }, + "balance": { + "type": "string" + } + }, + "required": ["hasCredits", "unlimited"] + }, + "fetchedAt": { + "type": "number" + } + }, + "required": ["fetchedAt"] + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "string" + } + }, + "required": ["id", "email", "isActive", "usage"] + } + } + }, + "required": ["accounts"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.codex.usage({\n ...\n})" + } + ] + } + }, "/find": { "get": { "operationId": "find.text", @@ -10008,6 +10130,119 @@ }, "required": ["type", "key", "token"] }, + "CodexMultiAccount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "codex-multi" + }, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "rateLimit": { + "type": "object", + "properties": { + "limited": { + "type": "boolean" + }, + "resetAt": { + "type": "number" + }, + "lastError": { + "type": "string" + } + }, + "required": ["limited"] + }, + "usage": { + "type": "object", + "properties": { + "planType": { + "type": "string" + }, + "primary": { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "type": "number" + }, + "resetAt": { + "type": "number" + } + }, + "required": ["usedPercent", "windowMinutes", "resetAt"] + }, + "secondary": { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "type": "number" + }, + "resetAt": { + "type": "number" + } + }, + "required": ["usedPercent", "windowMinutes", "resetAt"] + }, + "credits": { + "type": "object", + "properties": { + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + }, + "balance": { + "type": "string" + } + }, + "required": ["hasCredits", "unlimited"] + }, + "fetchedAt": { + "type": "number" + } + }, + "required": ["fetchedAt"] + } + }, + "required": ["id", "email", "refresh", "access", "expires"] + } + }, + "activeIndex": { + "default": 0, + "type": "number" + } + }, + "required": ["type", "accounts"] + }, "Auth": { "anyOf": [ { @@ -10018,6 +10253,9 @@ }, { "$ref": "#/components/schemas/WellKnownAuth" + }, + { + "$ref": "#/components/schemas/CodexMultiAccount" } ] }, diff --git a/packages/slack/AGENTS.md b/packages/slack/AGENTS.md new file mode 100644 index 000000000000..ce7f9664d4d5 --- /dev/null +++ b/packages/slack/AGENTS.md @@ -0,0 +1,23 @@ +# SLACK KNOWLEDGE BASE + +## OVERVIEW + +Slack integration package; runs Bolt app and bridges Slack threads to opencode sessions. + +## WHERE TO LOOK + +- Runtime entry: `packages/slack/src/index.ts` +- Package scripts/deps: `packages/slack/package.json` +- Setup/env docs: `packages/slack/README.md` + +## CONVENTIONS + +- Dev runner: `bun run src/index.ts`. +- Required envs: `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_APP_TOKEN`. +- Uses `@opencode-ai/sdk` for backend communication. + +## ANTI-PATTERNS + +- Don’t commit Slack secrets or `.env` credentials. +- Don’t assume stateless messages; behavior is thread/session-oriented. +- Don’t replace SDK calls with ad-hoc API wiring. diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md new file mode 100644 index 000000000000..f0d67d8b3e8e --- /dev/null +++ b/packages/ui/AGENTS.md @@ -0,0 +1,25 @@ +# UI KNOWLEDGE BASE + +## OVERVIEW + +Shared UI system: components, theme, icons, fonts, audio, and cross-app hooks/context. + +## WHERE TO LOOK + +- Components: `packages/ui/src/components` +- Theme engine: `packages/ui/src/theme` +- Shared hooks/context: `packages/ui/src/hooks`, `packages/ui/src/context` +- Assets: `packages/ui/src/assets` +- Tailwind generation: `packages/ui/script/tailwind.ts` + +## CONVENTIONS + +- Exports are path-based via `package.json` (`./*`, `./theme/*`, `./context/*`, etc.). +- Typecheck uses `tsgo --noEmit`; dev via Vite. +- Theme tokens/icons are reused across app/enterprise/desktop. + +## ANTI-PATTERNS + +- Don’t break export paths; downstream packages import via mapped subpaths. +- Don’t duplicate shared UI logic in app-specific packages. +- Don’t edit massive asset/icon sets without checking consumer impact. diff --git a/packages/web/AGENTS.md b/packages/web/AGENTS.md new file mode 100644 index 000000000000..34a9436f9f87 --- /dev/null +++ b/packages/web/AGENTS.md @@ -0,0 +1,24 @@ +# WEB KNOWLEDGE BASE + +## OVERVIEW + +Astro/Starlight docs + landing site; content-driven package with shared model/session rendering. + +## WHERE TO LOOK + +- Docs content: `packages/web/src/content/docs` +- Share/render components: `packages/web/src/components/share` +- Build config/scripts: `packages/web/package.json`, `packages/web/astro.config.mjs` +- Static assets: `packages/web/src/assets`, `packages/web/public` + +## CONVENTIONS + +- Dev/build/preview use Astro scripts in package.json. +- `dev:remote` sets `VITE_API_URL=https://api.opencode.ai`. +- Keep docs structure route-friendly (`index.mdx` and nested content folders). + +## ANTI-PATTERNS + +- Don’t rely on starter-template README defaults as project truth. +- Don’t mix generated/shared runtime data into docs content files. +- Don’t change docs route layout without checking linked nav/content config. diff --git a/script/cleanup-local.sh b/script/cleanup-local.sh new file mode 100755 index 000000000000..a0c72a271605 --- /dev/null +++ b/script/cleanup-local.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +BINARY_NAME="opencode" + +echo "Cleaning up local opencode installation..." + +# Remove bun link +if [ -L "$HOME/.bun/bin/$BINARY_NAME" ] || [ -f "$HOME/.bun/bin/$BINARY_NAME" ]; then + rm -f "$HOME/.bun/bin/$BINARY_NAME" + echo "Removed: ~/.bun/bin/$BINARY_NAME" +fi + +# Remove ~/.local/bin symlink +if [ -L "$HOME/.local/bin/$BINARY_NAME" ] || [ -f "$HOME/.local/bin/$BINARY_NAME" ]; then + rm -f "$HOME/.local/bin/$BINARY_NAME" + echo "Removed: ~/.local/bin/$BINARY_NAME" +fi + +# Remove dist folder +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$REPO_DIR/packages/opencode/dist" +if [ -d "$DIST_DIR" ]; then + rm -rf "$DIST_DIR" + echo "Removed: $DIST_DIR" +fi + +echo "Cleanup complete." + +# Verify removal +if command -v opencode &> /dev/null; then + echo "Warning: 'opencode' still found at: $(which opencode)" +else + echo "No 'opencode' binary in PATH." +fi diff --git a/script/rebuild-local.sh b/script/rebuild-local.sh new file mode 100755 index 000000000000..bd15a1f388bc --- /dev/null +++ b/script/rebuild-local.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +INSTALL_DIR="${HOME}/.local/bin" +BINARY_NAME="opencode" + +cd "$REPO_DIR/packages/opencode" + +echo "Building opencode..." +bun run script/build.ts --single + +# Determine platform +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +[[ "$ARCH" == "aarch64" ]] && ARCH="arm64" +[[ "$ARCH" == "x86_64" ]] && ARCH="x64" + +DIST_NAME="opencode-${OS}-${ARCH}" +BINARY_PATH="$REPO_DIR/packages/opencode/dist/${DIST_NAME}/bin/opencode" + +# Create install dir if needed +mkdir -p "$INSTALL_DIR" + +# Remove old symlink/binary +rm -f "$INSTALL_DIR/$BINARY_NAME" + +# Create symlink +ln -sf "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" + +echo "Installed: $INSTALL_DIR/$BINARY_NAME -> $BINARY_PATH" +echo "Version: $($INSTALL_DIR/$BINARY_NAME --version)" diff --git a/sdks/vscode/AGENTS.md b/sdks/vscode/AGENTS.md new file mode 100644 index 000000000000..636141b84cd4 --- /dev/null +++ b/sdks/vscode/AGENTS.md @@ -0,0 +1,24 @@ +# VSCODE SDK KNOWLEDGE BASE + +## OVERVIEW + +VS Code extension package that launches/focuses opencode terminal sessions and injects file refs. + +## WHERE TO LOOK + +- Extension entry: `sdks/vscode/src/extension.ts` +- Packaging/build scripts: `sdks/vscode/package.json` +- Bundling: `sdks/vscode/esbuild.js` +- Dev workflow: `sdks/vscode/README.md` + +## CONVENTIONS + +- `main` entry is `dist/extension.js`. +- Build path: `check-types` + `lint` + `esbuild`. +- Extension dev should open `sdks/vscode` directly in VS Code (not repo root). + +## ANTI-PATTERNS + +- Don’t edit dist output directly. +- Don’t skip lint/type checks before packaging. +- Don’t change keybindings/commands without updating contributes metadata.