diff --git a/.github/workflows/publication-lockfile-blob.yml b/.github/workflows/publication-lockfile-blob.yml new file mode 100644 index 0000000..a3dd58f --- /dev/null +++ b/.github/workflows/publication-lockfile-blob.yml @@ -0,0 +1,66 @@ +name: Prepare Vitest security-patch objects + +on: + push: + branches: [codex/publication-review-20260914] + paths: [.github/workflows/publication-lockfile-blob.yml] + +permissions: {} + +jobs: + prepare: + if: github.repository == 'azumag/opencode-rate-limit-fallback' && github.ref == 'refs/heads/codex/publication-review-20260914' + runs-on: ubuntu-latest + timeout-minutes: 3 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: aaad1fa88f102dffb40846c5024bb07a7a7ee85b + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '24' + package-manager-cache: false + - name: Resolve only the fixed Vitest patch without running package scripts + run: | + cp package.json "$RUNNER_TEMP/original-package.json" + cp package-lock.json "$RUNNER_TEMP/original-lock.json" + npm install --package-lock-only --ignore-scripts --save-exact --save-dev vitest@4.1.11 @vitest/coverage-v8@4.1.11 + npm audit --json > "$RUNNER_TEMP/fixed-audit.json" + - name: Verify change boundaries and create blobs only; no commits or refs + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 - <<'PY' + import copy, json, os, pathlib, urllib.request + temp = pathlib.Path(os.environ['RUNNER_TEMP']) + original = json.loads((temp / 'original-package.json').read_text()) + current = json.loads(pathlib.Path('package.json').read_text()) + expected = copy.deepcopy(original) + for name in ('vitest', '@vitest/coverage-v8'): + assert original['devDependencies'][name] == '^4.1.10' + expected['devDependencies'][name] = '4.1.11' + assert current == expected, 'Only the two Vitest dependency pins may change' + old_lock = json.loads((temp / 'original-lock.json').read_text()) + new_lock = json.loads(pathlib.Path('package-lock.json').read_text()) + assert {k: v for k, v in old_lock.items() if k != 'packages'} == {k: v for k, v in new_lock.items() if k != 'packages'} + before = old_lock['packages'] + after = new_lock['packages'] + changed = [p for p in sorted(set(before) | set(after)) if before.get(p) != after.get(p)] + assert all(p == '' or p == 'node_modules/vitest' or p.startswith('node_modules/@vitest/') for p in changed), 'Unrelated dependency updates are forbidden' + for name in ('vitest', '@vitest/coverage-v8', '@vitest/mocker'): + assert after['node_modules/' + name]['version'] == '4.1.11' + audit = json.loads((temp / 'fixed-audit.json').read_text()) + assert audit['metadata']['vulnerabilities']['total'] == 0 and 'error' not in audit + print('Changed lockfile entries: ' + json.dumps(changed)) + print('Full npm audit: zero vulnerabilities') + root = 'https://api.github.com/repos/azumag/opencode-rate-limit-fallback/git/blobs' + for name in ('package.json', 'package-lock.json'): + payload = json.dumps({'content': pathlib.Path(name).read_text(), 'encoding': 'utf-8'}).encode() + request = urllib.request.Request(root, data=payload, headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], 'Accept': 'application/vnd.github+json', 'Content-Type': 'application/json', 'User-Agent': 'publication-patch-review'}) + with urllib.request.urlopen(request, timeout=30) as response: + result = json.load(response) + print(name + ' verified blob: ' + result['sha']) + PY diff --git a/.github/workflows/publication-review.yml b/.github/workflows/publication-review.yml new file mode 100644 index 0000000..59958b3 --- /dev/null +++ b/.github/workflows/publication-review.yml @@ -0,0 +1,232 @@ +name: Publication review + +on: + workflow_dispatch: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Fetch public pull-request heads for history review + run: git fetch --no-tags origin '+refs/pull/*/head:refs/audit/pull/*' + + - name: Install checksum-pinned Gitleaks + shell: bash + run: | + set -euo pipefail + curl --fail --silent --show-error --location --retry 2 \ + https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz \ + -o "$RUNNER_TEMP/gitleaks.tar.gz" + echo "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --status + mkdir -p "$RUNNER_TEMP/gitleaks-bin" "$RUNNER_TEMP/publication-snapshot" + tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks + echo "$RUNNER_TEMP/gitleaks-bin" >> "$GITHUB_PATH" + git archive HEAD | tar -x -C "$RUNNER_TEMP/publication-snapshot" + + - name: Scan reachable history and current tracked files without printing secrets + shell: bash + run: | + set -euo pipefail + printf '[extend]\nuseDefault = true\n' > "$RUNNER_TEMP/gitleaks-default.toml" + touch "$RUNNER_TEMP/empty-gitleaksignore" + set +e + gitleaks git . --log-opts=--all --redact=100 --no-banner --no-color \ + --config "$RUNNER_TEMP/gitleaks-default.toml" \ + --gitleaks-ignore-path "$RUNNER_TEMP/empty-gitleaksignore" --ignore-gitleaks-allow \ + --max-decode-depth=3 --max-archive-depth=2 --exit-code=10 \ + --report-format=json --report-path="$RUNNER_TEMP/history-secrets.json" \ + > "$RUNNER_TEMP/history-scanner.log" 2>&1 + echo "$?" > "$RUNNER_TEMP/history-exit" + gitleaks dir "$RUNNER_TEMP/publication-snapshot" --redact=100 --no-banner --no-color \ + --config "$RUNNER_TEMP/gitleaks-default.toml" \ + --gitleaks-ignore-path "$RUNNER_TEMP/empty-gitleaksignore" --ignore-gitleaks-allow \ + --max-decode-depth=3 --max-archive-depth=2 --exit-code=10 \ + --report-format=json --report-path="$RUNNER_TEMP/current-secrets.json" \ + > "$RUNNER_TEMP/current-scanner.log" 2>&1 + echo "$?" > "$RUNNER_TEMP/current-exit" + exit 0 + + - name: Produce value-free secret and personal-data review summary + shell: bash + run: | + python3 - <<'PY' + import hashlib, json, os, pathlib, re, subprocess + temp = pathlib.Path(os.environ['RUNNER_TEMP']) + def git(*args): + return subprocess.check_output(['git', *args]) + summary = { + 'head': git('rev-parse', 'HEAD').decode().strip(), + 'reachable_commits': int(git('rev-list', '--all', '--count')), + 'refs': git('for-each-ref', '--format=%(refname)').decode().splitlines(), + 'tracked_files': git('ls-files', '-z').decode().rstrip('\0').split('\0'), + 'secret_scanner': 'gitleaks 8.30.1; built-in rules; no baseline or local allowlist', + 'scope_exclusions': ['unreachable/deleted Git objects', 'issue and PR discussion bodies', 'Actions logs and unrelated artifacts', 'already-published npm tarballs', 'local untracked files', 'GitHub secret settings'], + } + scan_ok = True + for scope in ('history', 'current'): + status = int((temp / f'{scope}-exit').read_text()) + report = temp / f'{scope}-secrets.json' + findings = json.loads(report.read_text()) if report.exists() else None + summary[scope + '_secrets'] = { + 'exit_code': status, + 'count': len(findings) if isinstance(findings, list) else None, + 'findings': [{k: f.get(k) for k in ('RuleID', 'File', 'StartLine', 'Commit')} for f in (findings or [])], + } + scan_ok = scan_ok and status == 0 and isinstance(findings, list) and not findings + patterns = { + 'email': re.compile(r'[A-Za-z0-9_.+%-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'), + 'personal_home_path': re.compile(r'/(?:Users|home)/[A-Za-z0-9_.-]+/'), + 'private_ipv4': re.compile(r'(? "$RUNNER_TEMP/package-preview.json" + set +e + npm audit --json > "$RUNNER_TEMP/npm-audit.json" 2> "$RUNNER_TEMP/npm-audit.log" + echo "$?" > "$RUNNER_TEMP/npm-audit-exit" + npm audit --omit=dev --json > "$RUNNER_TEMP/npm-audit-production.json" 2> "$RUNNER_TEMP/npm-audit-production.log" + echo "$?" > "$RUNNER_TEMP/npm-audit-production-exit" + npm view @azumag/opencode-rate-limit-fallback dist-tags --json > "$RUNNER_TEMP/npm-dist-tags.json" 2> "$RUNNER_TEMP/npm-dist-tags.log" + echo "$?" > "$RUNNER_TEMP/npm-dist-tags-exit" + set -e + python3 - <<'PYCODE' + import json, os, pathlib, re + temp = pathlib.Path(os.environ['RUNNER_TEMP']) + preview = json.loads((temp / 'package-preview.json').read_text())[0] + paths = [f['path'] for f in preview['files']] + required = {'README.md', 'README.ja.md', 'docs/v1-reference.md', 'docs/quota-wait-mode.md', 'LICENSE', 'package.json', 'dist/v2.js', 'dist/v2.d.ts'} + assert required.issubset(paths), 'Missing distribution files' + assert all(p.startswith('dist/') or p in required for p in paths), 'Unexpected distribution files' + assert not any(p.endswith(('.map', '.json', '.log', '.pem', '.key')) and p != 'package.json' for p in paths), 'Unexpected sensitive/generated package file' + doc_files = ['README.md', 'README.ja.md', 'docs/v1-reference.md', 'docs/quota-wait-mode.md'] + for file in doc_files: + text = pathlib.Path(file).read_text() + for target in re.findall(r'\[[^\]]+\]\(([^)]+)\)', text): + if '://' not in target and not target.startswith('#'): + assert (pathlib.Path(file).parent / target.split('#')[0]).is_file(), f'Broken local documentation link in {file}' + # Both top-level languages must use exactly the same runnable snippets. + code_blocks = lambda file: re.findall(r'```(?:json|sh)\n(.*?)```', pathlib.Path(file).read_text(), flags=re.S) + assert code_blocks('README.md') == code_blocks('README.ja.md'), 'Language examples diverged' + audit = {} + audit_ok = True + for name in ('npm-audit', 'npm-audit-production'): + status = int((temp / (name + '-exit')).read_text()) + raw = json.loads((temp / (name + '.json')).read_text()) + counts = raw.get('metadata', {}).get('vulnerabilities') + details = [] + for package, item in raw.get('vulnerabilities', {}).items(): + details.append({ + 'package': package, 'severity': item.get('severity'), + 'is_direct': item.get('isDirect'), 'range': item.get('range'), + 'fix_available': item.get('fixAvailable'), + 'via': [value if isinstance(value, str) else {key: value.get(key) for key in ('name', 'title', 'url', 'severity', 'range')} for value in item.get('via', [])], + }) + audit[name] = {'exit_code': status, 'vulnerabilities': counts, 'has_error': 'error' in raw, 'details': details} + audit_ok = audit_ok and status == 0 and isinstance(counts, dict) and counts.get('total') == 0 and 'error' not in raw + tags = None + if int((temp / 'npm-dist-tags-exit').read_text()) == 0: + tags = json.loads((temp / 'npm-dist-tags.json').read_text()) + result = {'package': preview['name'], 'version': preview['version'], 'file_count': len(paths), 'files': paths, 'documentation_links': 'passed', 'bilingual_examples': 'identical', 'dependency_audit': audit, 'published_dist_tags_at_review': tags} + (temp / 'publication-package-summary.json').write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result, indent=2)) + if not audit_ok: + raise SystemExit('Dependency audit did not pass; inspect the value-free summary. No publish has occurred.') + PYCODE + + - name: Archive only the scanned current source, never Git history or credentials + run: tar -czf "$RUNNER_TEMP/publication-source.tar.gz" -C "$RUNNER_TEMP/publication-snapshot" . + + - name: Upload sanitized summary and scanned source for review + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-review + path: | + ${{ runner.temp }}/publication-review-summary.json + ${{ runner.temp }}/publication-source.tar.gz + ${{ runner.temp }}/publication-package-summary.json + retention-days: 3 + if-no-files-found: error + + - name: Preserve value-free findings even when a validation gate fails + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: publication-review-summary + path: | + ${{ runner.temp }}/publication-review-summary.json + ${{ runner.temp }}/publication-package-summary.json + retention-days: 3 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 2da6d97..4f64591 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,24 @@ bun.lock .DS_Store dist/ tmp/* -coverage/ \ No newline at end of file +coverage/ +*.tsbuildinfo + +# Local credentials and machine-specific configuration. +.env +.env.* +!.env.example +!.env.sample +.npmrc +auth.json +*.pem +*.key +.opencode/auth.json +rate-limit-fallback.json +rate-limit-fallback-health.json +.opencode/rate-limit-fallback.json +.opencode/rate-limit-fallback-health.json + +# Generated diagnostic data can contain local paths or session metadata. +test-results*.json +publication-review-summary.json diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 0000000..07560b9 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,133 @@ +# @azumag/opencode-rate-limit-fallback + +[English](README.md) | **日本語** + +レート制限からの復帰を補助する、非公式のOpenCodeプラグインです。**2.xのエントリーポイントは同じセッションを待機・再開するもので、モデル切り替えは行いません。** モデル切り替えは旧1.x系の機能です。 + +プロバイダーの利用上限を解除したり、追加の利用枠を取得したりするツールではありません。アカウントや認証情報は管理せず、OpenCodeの公式製品・公認プラグインでもありません。 + +## バージョンの選び方 + +| OpenCodeのプラグイン方式 | パッケージ | 対応機能 | +| --- | --- | --- | +| `plugins`を使うv2 / Web | 2.x、npmタグ`opencode-v2` | `wait`のみ。同じセッションを指数バックオフで再開 | +| `plugin`を使うv1 | 1.x。掲載する固定バージョンは`1.70.11` | モデル切り替え、`cycle`、`stop`、`retry-last`、固定間隔の`wait` | + +このブランチのソースは**2.0.3**です。公開前はnpmタグが以前のリリースを指している場合があります。後述の待機時間の検証とHTTP 401/403への対策は2.0.3で追加したもので、既存の2.0.2には適用されません。 + +```sh +npm view @azumag/opencode-rate-limit-fallback dist-tags --json +``` + +v1のホストに2.xを導入しないでください。開発用のOpenCode plugin / SDKパッケージは`1.18.16`に固定していますが、**すべてのv2/Web版で実機確認済みという意味ではありません。** v2アダプターのテストはフックを模擬したものです。無人運用に使う前に、ホストが必要な`prompt`・`retry`フックに対応していることを確認してください。 + +## 最小構成:v2で同じモデルを待つ + +**OpenCode本体の設定**にプラグインを追加します。v1用の`latest`ではなくv2用タグを指定します。リリースを確認した後は、再現性のためにバージョン番号を固定してください。 + +```json +{ + "plugins": ["@azumag/opencode-rate-limit-fallback@opencode-v2"] +} +``` + +これとは**別のプラグイン設定ファイル**を`~/.opencode/rate-limit-fallback.json`に作成します。ディレクトリがなければ先に作成してください。 + +```json +{ + "enabled": true, + "fallbackMode": "wait", + "cooldownMs": 60000, + "fallbackModels": [] +} +``` + +プラグインを実行しているOpenCodeプロセスを再起動します。モデルの選択と認証はOpenCode本体で行います。**このプラグインのJSONにAPIキー、Cookie、パスワード、プロバイダーの認証情報を記入しないでください。** + +`fallbackMode: "wait"`の明示が必要です。共有設定の初期値は`"cycle"`であり、そのままではv2アダプターは動作しません。`wait`は切り替え先を選ばないため、`fallbackModels`は空配列で問題ありません。 + +## v2の動作と設定 + +HTTP 429や、レート・利用枠に関する一部のエラー文言を検出すると、既存セッションの再試行を要求します。別のプロバイダーやモデルは選択しません。 + +上の設定では、該当するエラーが繰り返されると、待機時間が**60秒、120秒、240秒、…、1時間**と増えます。新しい空でないユーザープロンプトが届くと、アダプターの再開タイマーを取り消して待機時間を初期化します。自動再開用の空プロンプトは待機回数を引き継ぎます。成功しただけでは、このアダプターの待機回数は初期化されません。 + +| 設定 | 動作 | +| --- | --- | +| `enabled` | 初期値`true`。`false`にしてホストを再起動すると無効化 | +| `fallbackMode` | v2では`"wait"`が必須。他のモードではフックを登録しない | +| `cooldownMs` | 初回待機時間。初期値`60000`ミリ秒、実効下限`1000`ミリ秒 | +| `fallbackModels` | v2では切り替え先の選択に使用しない。`[]`でよい | + +2.0.3以降、`cooldownMs`は有限・非負のJSON数値で、上限は`2147483397`です。不正な値では、危険なタイマーを作らず、フック登録前にプラグインの初期化を失敗させます。1時間を超える有効な設定は維持し、バックオフの上限は常に1時間ではなく`max(3600000, cooldownMs)`となります。プロバイダーが長い間隔を要求する場合を除き、まずは例の60秒を使用してください。 + +2.0.3以降、明示的なHTTP **401/403**は、文面にquotaが含まれていても、このアダプターの再試行を停止し、そのセッションに予約済みの再開タイマーも取り消します。そのステータス情報が渡されないエラーは文言判定に依存するため、誤検出の可能性が残ります。 + +### 重要な制約 + +- `wait`には、意図的に**総再試行回数・総待機時間の上限がありません**。アダプターが想定するクライアント側の試行上限である5回目以降は、待機時間に250ミリ秒を加えて、同じセッションを空プロンプトで再開します。サーバー側の利用上限を解除する動作でも、必ず復旧する保証でもありません。 +- **`Retry-After`や利用枠のリセット時刻は解析しません。** 待機時間のランダム化もありません。プロバイダー指定の再試行間隔を守れない運用には使用せず、大量のセッションを同時待機させないでください。 +- v1の`retryPolicy`、サーキットブレーカー、独自エラーパターン、メトリクス、動的モデル優先順位、設定の自動再読込、`headlessOnRateLimit`は、**v2のエントリーポイントでは未実装**です。設定に書いてもv2の安全上限にはなりません。 +- v2には停止・セッション削除専用のライフサイクルフックがありません。画面の停止ボタン、セッション削除、ブラウザーのタブを閉じる操作だけで、サーバー側の予約タイマーが消えるとは限りません。確実にこのプラグインの予約処理を止めるには、**OpenCodeのホストプロセスを終了し、`enabled: false`にして再起動**してください。新しいプロンプトは前のタイマーを取り消しますが、新たな処理も開始します。 + +遅延後の再開によって、エージェントに許可されたツール操作が続行される可能性があります。時間が経ってから再開することを踏まえ、ホストのツール権限と承認設定を調整してください。 + +## 設定ファイルの探索順 + +最初に読み込み・受理できた設定を使用し、複数ファイルは重ね合わせません。v2の探索順は次のとおりです。 + +1. `/.opencode/rate-limit-fallback.json`、次に`/rate-limit-fallback.json`。 +2. `$HOME/.opencode/rate-limit-fallback.json`、次に`$HOME/rate-limit-fallback.json`。 +3. `$XDG_CONFIG_HOME/opencode/rate-limit-fallback.json`、次に`$XDG_CONFIG_HOME/rate-limit-fallback.json`。未設定時の`XDG_CONFIG_HOME`は`$HOME/.config`です。 + +v2はプロジェクトとworktreeに同じディレクトリを渡します。v1は別のworktreeをプロジェクトより先に探索できます。プロジェクト内の設定がグローバル設定を上書きするため、信頼できないリポジトリを開く前に内容を確認してください。v2で設定を変更した場合はホストの再起動が必要です。 + +## OpenCode Goの規約・料金・プライバシー + +**2026年9月14日**に[Goの公式文書](https://opencode.ai/docs/go/)と[利用規約](https://opencode.ai/legal/terms-of-service)を確認しています。規約は変更される場合があります。 + +Goは通常のコーディングエージェント利用を説明する一方、ホスト型サービスの規約には、アカウントを使った上限回避、過大な負荷、一部の自動処理に関する制限があります。**READMEの記載やバックオフの実装だけで、無制限の無人運用が許可されるわけではありません。** 判断が曖昧な運用は提供元に確認してください。本プロジェクトは規約適合を保証しません。MITライセンスはプラグインのコードに対するもので、ホスト型モデルの利用許諾ではありません。 + +正当に利用できるアカウントと、対応するホストのリクエスト経路を使用してください。上限回避のためのアカウント切り替えやクライアント・セッション識別情報の偽装は行わないでください。本プラグインは独自のプロバイダー向け通信を組み立てず、OpenCodeに処理を委ねます。クライアント・セッションヘッダーの送信はホスト側の責任範囲です。 + +Goの**Use balance**を有効にすると、定額枠到達後にZen残高を消費する場合があります。自動再開の前に課金設定を確認してください。また、学習利用と保存期間はモデルごとに異なり、Goの全モデルが同じプライバシー条件とは限りません。v1のフォールバックでは、会話内容や添付が**別のプロバイダー**へ再送され、料金やデータ取扱条件が変わる可能性があります。 + +## データの取り扱いと安全な報告 + +v2アダプター自身は、プロバイダーの認証情報を読み込まず、会話ログを出力せず、独立したテレメトリー送信先も呼び出しません。ローカルのプラグイン設定を読み、セッションID・待機回数・タイマーをメモリー内に保持します。ただし再開後の処理はOpenCodeと設定先プロバイダーを通るため、**オフライン処理でも、ホスト側がログを保存しないという保証でもありません。** + +旧v1の機能では、モデル健全性の統計をローカル保存し、任意設定のメトリクスを出力し、学習したエラーパターンを設定に書き戻し、OpenCodeのアプリケーションログへ診断情報を送る場合があります。パス、セッションID、設定値、プロバイダーのエラーを含む可能性があり、完全な秘密情報のマスキング機能ではありません。 + +Issueに共有する前に、ログ、設定、スクリーンショット、テスト結果、コミット情報を確認・伏せ字化してください。`.env`、`auth.json`、トークン入りの`.npmrc`、秘密鍵、実際の会話はアップロードしないでください。`.gitignore`は今後の混入防止には役立ちますが、Git履歴、過去のPR、公開済みパッケージ、キャッシュにある情報を削除するものではありません。自動検査で秘密情報が検出されなかったことと、個人情報が一切存在しないことは別です。 + +## OpenCode v1を使う場合 + +v1系のパッケージを維持してください。 + +```json +{ + "plugin": ["@azumag/opencode-rate-limit-fallback@1.70.11"] +} +``` + +上の最小構成の`wait`設定はv1でも使用できますが、待機時間は指数的には増加せず固定です。モデル切り替えやヘッドレス実行を設定する前に、[v1設定リファレンス(英語)](docs/v1-reference.md)と[v1の待機モード(英語)](docs/quota-wait-mode.md)を確認してください。 + +## 開発と検証 + +リポジトリのCIはNode.js 24を使用します。信頼できるチェックアウト上で実行してください。 + +```sh +npm ci +npm run typecheck +npm test +npm run build +npm pack --dry-run --ignore-scripts +``` + +テストはモックや合成入力を使います。提供元の許諾、実際の利用枠リセット、すべてのホストのライフサイクル動作を証明するものではありません。公開前検査のワークフローは、到達可能なGit履歴と追跡対象ファイルをGitleaksで調べ、検出値を伏せて集計します。集計には検査対象外を記載しており、削除済み・到達不能なオブジェクト、外部コピー、ホスト上のすべての議論やログは含みません。 + +英語版と日本語版のREADMEは一緒に更新してください。テスト用データに実際の認証情報や本番の会話を追加しないでください。 + +## ライセンス + +[MIT](LICENSE)。OpenCodeやプロバイダーの名称は対応対象を示すもので、公認を意味しません。 diff --git a/README.md b/README.md index a5abf6d..8d67d61 100644 --- a/README.md +++ b/README.md @@ -1,889 +1,133 @@ # @azumag/opencode-rate-limit-fallback -[![npm version](https://badge.fury.io/js/@azumag%2Fopencode-rate-limit-fallback.svg)](https://www.npmjs.com/package/@azumag/opencode-rate-limit-fallback) - -OpenCode plugin that automatically switches to fallback models when rate limited. - -## Features - -- Detects rate limit errors (429, `rate_limit_error`, "usage limit", "quota exceeded", "high concurrency", etc.) -- Ignores known benign Anthropic billing notices so they do not trigger false-positive fallbacks -- Automatically aborts the current request and retries with a fallback model -- Preserves the active OpenCode agent (`build`, `plan`, or a custom agent) when retrying -- Configurable fallback model list with priority order -- Four fallback modes: `cycle`, `stop`, `retry-last`, and single-model `wait` -- **Headless mode support** (`opencode run`): disable fallback, abort, or use single-model quota wait -- Session model tracking for sequential fallback across multiple rate limits -- Cooldown period to prevent immediate retry on rate-limited models -- **Exponential backoff with configurable retry policies** - - Supports immediate, exponential, and linear backoff strategies - - Jitter to prevent thundering herd problem - - Configurable retry limits and timeouts - - Retry statistics tracking - - Toast notifications for user feedback - - Subagent session support with fallback on the failed child session - - Configurable maximum subagent nesting depth - - **Circuit breaker pattern** to prevent cascading failures from consistently failing models - - **Metrics collection** to track rate limits, fallbacks, and model performance - - **Configuration hot reload** - Reload configuration changes without restarting OpenCode - - **Dynamic fallback model prioritization** - Automatically reorders models based on success rate, response time, and usage frequency - -## Installation - -OpenCode v2 / Web uses the `plugins` key: +**English** | [日本語](README.ja.md) -```json -{ - "plugins": ["@azumag/opencode-rate-limit-fallback@2.0.2"] -} -``` - -OpenCode v1 must stay on the latest 1.x release: - -```json -{ - "plugin": ["@azumag/opencode-rate-limit-fallback@1.70.11"] -} -``` - -Version 2 is published under the `opencode-v2` npm tag so unpinned OpenCode v1 -installations do not receive the incompatible v2 entry point. - -The v2 entry point currently implements `fallbackMode: "wait"`. Model-switching -modes remain available in the v1-compatible 1.x line. - -In v2 wait mode, `cooldownMs` is the initial delay. Consecutive rate-limit -failures use exponential backoff (`1x`, `2x`, `4x`, ...), capped at one hour. -A new user prompt resets the delay to `cooldownMs`; automatic resume attempts -preserve the existing backoff sequence. - -OpenCode will automatically install the plugin on startup. - -This release is tested with OpenCode `1.18.16`. Older OpenCode releases may -fail to install the matching plugin SDK dependencies; upgrade OpenCode before -installing this plugin. - -## Configuration - -Create a configuration file at one of these locations: - -**Config file search order (highest to lowest priority):** -1. `/.opencode/rate-limit-fallback.json` -2. `/rate-limit-fallback.json` -3. `/.opencode/rate-limit-fallback.json` -4. `/rate-limit-fallback.json` -5. `~/.opencode/rate-limit-fallback.json` (recommended for most users) -6. `~/.config/opencode/rate-limit-fallback.json` - -> **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6). - -### Example Configuration - -```json -{ - "enabled": true, - "cooldownMs": 60000, - "fallbackMode": "cycle", - "maxSubagentDepth": 10, - "enableSubagentFallback": true, - "fallbackModels": [ - { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20250514" }, - { "providerID": "google", "modelID": "gemini-2.5-pro" }, - { "providerID": "google", "modelID": "gemini-2.5-flash" } - ], - "retryPolicy": { - "maxRetries": 3, - "strategy": "exponential", - "baseDelayMs": 1000, - "maxDelayMs": 30000, - "jitterEnabled": true, - "jitterFactor": 0.1, - "timeoutMs": 60000 - }, - "metrics": { - "enabled": true, - "output": { - "console": true, - "format": "pretty" - }, - "resetInterval": "daily" - }, - "circuitBreaker": { - "enabled": true, - "failureThreshold": 5, - "recoveryTimeoutMs": 60000, - "halfOpenMaxCalls": 1, - "successThreshold": 2 - }, - "configReload": { - "enabled": true, - "watchFile": true, - "debounceMs": 1000, - "notifyOnReload": true - } -} -``` - -### Configuration Options - - | Option | Type | Default | Description | - |--------|------|---------|-------------| - | `enabled` | boolean | `true` | Enable/disable the plugin | - | `cooldownMs` | number | `60000` | Cooldown period (ms) before retrying a rate-limited model | - | `fallbackMode` | string | `"cycle"` | Behavior when all models are exhausted (see below) | - | `headlessOnRateLimit` | string | `undefined` | Headless mode behavior on rate limit (see below) | - | `fallbackModels` | array | See below | List of fallback models in priority order | - | `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies | - | `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions | - | `retryPolicy` | object | See below | Retry policy configuration (see below) | - | `circuitBreaker` | object | See below | Circuit breaker configuration (see below) | - | `errorPatterns` | object | See below | Advanced rate-limit matching overrides and false-positive ignores | - | `configReload` | object | See below | Configuration hot reload settings (see below) | - | `dynamicPrioritization` | object | See below | Dynamic prioritization settings (see below) | - -### Error Pattern Overrides - -The plugin now applies a small ignore list before general substring matching so benign provider notices do not trigger a fallback by mistake. - -Built-in ignore patterns: - -- `not your plan limits` -- `draw from your extra usage` - -Strong signals still win over the ignore list: - -- HTTP `429` -- explicit `rate_limit_error` - -Use `errorPatterns.ignorePatterns` to add your own false-positive suppressions: - -```json -{ - "errorPatterns": { - "ignorePatterns": [ - "not your plan limits", - "draw from your extra usage", - "internal billing notice" - ] - } -} -``` - -The configured array replaces the built-in list. Include the built-in entries when extending it, as shown above. Set it to `[]` to disable ignore matching entirely. Entries must be non-empty strings (or `RegExp` values when configuring the plugin programmatically), and changes are applied by config hot reload. - -### Automatic Pattern Learning - -Pattern learning can recognize a new provider error format after it appears repeatedly. It observes HTTP `429` responses and errors containing explicit rate-limit signals such as quota, throttling, or structured rate-limit codes. Arbitrary server and application errors are not learned. - -```json -{ - "errorPatterns": { - "enableLearning": true, - "autoApproveThreshold": 0.8, - "maxLearnedPatterns": 20, - "minErrorFrequency": 3, - "learningWindowMs": 86400000 - } -} -``` - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `errorPatterns.enableLearning` | boolean | `false` | Enable automatic pattern learning | -| `errorPatterns.autoApproveThreshold` | number | `0.8` | Minimum confidence from `0` to `1` required to save a candidate | -| `errorPatterns.maxLearnedPatterns` | integer | `20` | Maximum saved patterns; highest-confidence patterns are retained | -| `errorPatterns.minErrorFrequency` | integer | `3` | Required observations inside the learning window | -| `errorPatterns.learningWindowMs` | number | `86400000` | Observation window in milliseconds | - -The provider from OpenCode's message event is used even when the error text does not name it. After a candidate reaches the frequency and confidence thresholds, the plugin atomically updates the config file and immediately refreshes the live registry; hot reload is not required. Only extracted phrases, status codes, and structured error codes are stored, not the complete raw error body. - -Learned entries with confidence at or below `0.7` remain visible for review but are not used for rate-limit detection. Lowering `autoApproveThreshold` below that value can persist candidates for inspection; it does not lower the runtime detection safety floor. - -Learned entries are validated during startup and hot reload. Strict configuration validation rejects malformed entries; non-strict mode excludes only the invalid entries. - -### Dynamic Prioritization - -The dynamic prioritization feature automatically reorders your fallback models based on their performance metrics, helping you use the most reliable and fastest models first. - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | boolean | `false` | Enable/disable dynamic prioritization | -| `updateInterval` | number | `10` | Number of requests between score updates (performance optimization) | -| `successRateWeight` | number | `0.6` | Weight for success rate (0-1) | -| `responseTimeWeight` | number | `0.3` | Weight for response time (0-1) | -| `recentUsageWeight` | number | `0.1` | Weight for recent usage frequency (0-1) | -| `minSamples` | number | `3` | Minimum samples before using dynamic ordering | -| `maxHistorySize` | number | `100` | Maximum history size for usage tracking | - -#### How It Works - -Dynamic prioritization calculates a score for each model based on three factors: - -1. **Success Rate** (default weight: 0.6) - - Based on health score from HealthTracker - - Higher success rate = higher score - -2. **Response Time** (default weight: 0.3) - - Faster response times get higher scores - - Thresholds: <500ms (excellent), >5000ms (poor) - -3. **Recent Usage** (default weight: 0.1) - - Recently used models get a small boost - - Decays over 24 hours - -The final score is calculated as: -``` -score = (healthScore / 100 * successRateWeight) + - (normalizedResponseTime * responseTimeWeight) + - (normalizedRecentUsage * recentUsageWeight) -``` - -#### Learning Phase - -- Uses static ordering until `minSamples` models have sufficient data -- Default: 3 models need at least 3 requests each -- Ensures reliable data before reordering - -#### Configuration Examples - -**Enable with defaults:** -```json -{ - "dynamicPrioritization": { - "enabled": true - } -} -``` - -**Full configuration:** -```json -{ - "dynamicPrioritization": { - "enabled": true, - "updateInterval": 10, - "successRateWeight": 0.6, - "responseTimeWeight": 0.3, - "recentUsageWeight": 0.1, - "minSamples": 3, - "maxHistorySize": 100 - } -} -``` - -**Prioritize speed over reliability:** -```json -{ - "dynamicPrioritization": { - "enabled": true, - "successRateWeight": 0.4, - "responseTimeWeight": 0.5, - "recentUsageWeight": 0.1 - } -} -``` - -#### Important Notes - -- **Disabled by default**: Set `enabled: true` to activate -- **Requires health tracking**: Uses HealthTracker data for success rates -- **Weights must sum to ~1.0**: Ensure optimal scoring behavior -- **Hot reload supported**: Can be enabled/disabled without restarting OpenCode - -### Git Worktree Support - -When using git worktrees, the plugin searches for config files in the worktree directory first, before the project directory. This allows you to have different fallback configurations for different worktrees. - -**Example structure:** -``` -my-repo/ - .git/ - .opencode/rate-limit-fallback.json (project-level config) - my-worktree/ (worktree) - .opencode/rate-limit-fallback.json (worktree-specific, higher priority) -``` +An unofficial OpenCode plugin for recovering from rate limits. **The 2.x entry point waits and resumes the same session; it does not switch models.** Model switching belongs to the legacy 1.x plugin. -**Config file search order with worktrees (highest to lowest priority):** -1. `/.opencode/rate-limit-fallback.json` -2. `/rate-limit-fallback.json` -3. `/.opencode/rate-limit-fallback.json` -4. `/rate-limit-fallback.json` -5. `~/.opencode/rate-limit-fallback.json` -6. `~/.config/opencode/rate-limit-fallback.json` +This is a recovery tool, not a way to remove provider quotas or obtain extra usage. It does not manage accounts or credentials, and is not affiliated with or endorsed by OpenCode. -> **Note**: If you're using git worktrees and want different configurations per worktree, create config files in the worktree directories (locations 1-2). Otherwise, a single project-level or global config is sufficient. +## Choose the right version -### Headless Mode (`opencode run`) +| OpenCode plugin interface | Package line | Supported behavior | +| --- | --- | --- | +| v2 / Web, using `plugins` | 2.x, npm tag `opencode-v2` | `wait` only: same-session resume with exponential backoff | +| v1, using `plugin` | 1.x; documented pin `1.70.11` | Model fallback, `cycle`, `stop`, `retry-last`, and fixed-interval `wait` | -When running in headless mode (no TUI), model fallback is disabled by default because headless sessions should use their configured model only. +The source version in this branch is **2.0.3**. The npm tag may still point to an earlier release until publication. The cooldown validation and HTTP 401/403 safeguards described below were added in 2.0.3; they are not retroactive fixes for 2.0.2. -`fallbackMode: "wait"` is the exception: it keeps the same model and runs the -quota-wait loop in headless mode unless `headlessOnRateLimit` is `"abort"`. - -You can control what happens when a rate limit is detected in headless mode using the `headlessOnRateLimit` option: - -| Value | Description | -|-------|-------------| -| *(not set)* | Default behavior — do nothing, let the server's retry loop handle it | -| `"ignore"` | Same as default — do nothing | -| `"abort"` | Abort the session immediately to terminate the prompt | - -The `"abort"` option is useful when you want `opencode run` to fail fast on rate limits rather than waiting for the server's retry loop, which may retry indefinitely. - -```json -{ - "headlessOnRateLimit": "abort" -} -``` - -### Fallback Modes - -| Mode | Description | -|------|-------------| -| `"cycle"` | Reset and retry from the first model when all models are exhausted (default) | -| `"stop"` | Stop and show error when all models are exhausted | -| `"retry-last"` | Try the last model once more, then reset to first on next prompt | -| `"wait"` | Keep the current model, wait `cooldownMs`, and retry it indefinitely on rate limits | - -### Retry Policy - -The retry policy controls how the plugin handles retry attempts after rate limits, with support for exponential backoff to reduce API pressure. - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `maxRetries` | number | `3` | Maximum retry attempts before giving up | -| `strategy` | string | `"immediate"` | Backoff strategy: `"immediate"`, `"exponential"`, or `"linear"` | -| `baseDelayMs` | number | `1000` | Base delay in milliseconds for backoff calculation | -| `maxDelayMs` | number | `30000` | Maximum delay in milliseconds | -| `jitterEnabled` | boolean | `false` | Add random jitter to delays to prevent thundering herd | -| `jitterFactor` | number | `0.1` | Jitter factor (0.1 = 10% variance) | -| `timeoutMs` | number | `undefined` | Overall timeout for all retry attempts (optional) | - -#### Retry Strategies - -**Immediate** (default, no backoff) -``` -delay = 0ms -``` -Retries immediately without any delay. This is the original behavior and maintains backward compatibility. - -**Exponential** (recommended for production) -``` -delay = min(baseDelayMs * (2 ^ attempt), maxDelayMs) -delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled -``` -Exponential backoff that doubles the delay after each attempt. This is the standard pattern for rate limit handling. - -Example with `baseDelayMs: 1000`, `maxDelayMs: 30000`, and `jitterFactor: 0.1`: -- Attempt 0: ~1000ms (with jitter: 900-1100ms) -- Attempt 1: ~2000ms (with jitter: 1800-2200ms) -- Attempt 2: ~4000ms (with jitter: 3600-4400ms) -- Attempt 3: ~8000ms (with jitter: 7200-8800ms) -- Attempt 4+: ~16000ms (capped at maxDelayMs: 30000ms) - -**Linear** -``` -delay = min(baseDelayMs * (attempt + 1), maxDelayMs) -delay = delay * (1 + random(-jitterFactor, jitterFactor)) // if jitter enabled -``` -Linear backoff that increases delay by a constant amount after each attempt. - -Example with `baseDelayMs: 1000` and `maxDelayMs: 5000`: -- Attempt 0: ~1000ms -- Attempt 1: ~2000ms -- Attempt 2: ~3000ms -- Attempt 3: ~4000ms -- Attempt 4+: ~5000ms (capped at maxDelayMs) - -#### Jitter - -Jitter adds random variation to delay times to prevent the "thundering herd" problem, where multiple clients retry simultaneously and overwhelm the API. - - - Recommended for production environments with multiple concurrent users - - `jitterFactor: 0.1` adds ±10% variance to delay times - - Example: With base delay of 1000ms and jitterFactor 0.1, actual delay will be 900-1100ms - -### Circuit Breaker - -The circuit breaker pattern prevents cascading failures by temporarily disabling models that are consistently failing (not due to rate limits). - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `circuitBreaker.enabled` | boolean | `false` | Enable/disable circuit breaker | -| `circuitBreaker.failureThreshold` | number | `5` | Consecutive failures before opening circuit | -| `circuitBreaker.recoveryTimeoutMs` | number | `60000` | Wait time before attempting recovery (ms) | -| `circuitBreaker.halfOpenMaxCalls` | number | `1` | Max calls allowed in HALF_OPEN state | -| `circuitBreaker.successThreshold` | number | `2` | Successes needed to close circuit | - -#### How It Works - -The circuit breaker maintains three states for each model: - -1. **CLOSED State**: Normal operation, requests pass through - - Failures are counted until the threshold is reached - - On threshold breach, transitions to OPEN state - -2. **OPEN State**: Model is failing, requests fail fast - - The circuit is "open" to prevent unnecessary API calls - - No requests are allowed through - - After the recovery timeout, transitions to HALF_OPEN state - -3. **HALF_OPEN State**: Testing if model recovered after timeout - - A limited number of test requests are allowed - - On success, transitions back to CLOSED state - - On failure, returns to OPEN state - -#### Important Notes - -- **Rate limit errors are NOT counted as failures**: The circuit breaker only tracks actual failures, not rate limit errors -- **Disabled by default**: Set `circuitBreaker.enabled: true` to activate this feature -- **Per-model tracking**: Each model has its own circuit state -- **Toast notifications**: Users are notified when circuits open/close for awareness - -#### Configuration Recommendations - -| Environment | failureThreshold | recoveryTimeoutMs | halfOpenMaxCalls | -|-------------|------------------|-------------------|------------------| -| Development | 3 | 30000 | 1 | -| Production | 5 | 60000 | 1 | -| High Availability | 10 | 30000 | 2 | - -### Configuration Hot Reload - -The plugin supports automatic configuration reloading without requiring you to restart OpenCode. When you edit your configuration file, the plugin detects the changes and applies them seamlessly. - -#### Configuration Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `configReload.enabled` | boolean | `false` | Enable/disable configuration hot reload | -| `configReload.watchFile` | boolean | `true` | Watch config file for changes | -| `configReload.debounceMs` | number | `1000` | Debounce delay (ms) to handle multiple file writes | -| `configReload.notifyOnReload` | boolean | `true` | Show toast notifications on reload | - -#### How It Works - -1. **File Watching**: When enabled, the plugin watches your configuration file for changes -2. **Debouncing**: Multiple file writes (e.g., from editors) are debounced to prevent unnecessary reloads -3. **Validation**: New configuration is validated before applying it -4. **Graceful Application**: If valid, the new configuration is applied without interrupting active sessions -5. **Toast Notifications**: You receive toast notifications for successful or failed reloads - -#### Behavior - -**What gets reloaded:** -- Fallback model list -- Cooldown periods -- Fallback mode -- Subagent fallback and maximum-depth settings -- Retry policies -- Circuit breaker settings -- Custom, ignored, and learned error patterns -- Metrics configuration -- Log configuration -- Health tracking settings - -**What doesn't change:** -- Active session states -- Rate-limited model tracking -- Health tracking data -- Metrics history - -#### Configuration Examples - -**Enable hot reload:** -```json -{ - "configReload": { - "enabled": true - } -} +```sh +npm view @azumag/opencode-rate-limit-fallback dist-tags --json ``` -**Full configuration:** -```json -{ - "configReload": { - "enabled": true, - "watchFile": true, - "debounceMs": 1000, - "notifyOnReload": true - } -} -``` - -#### Important Notes - -- **Disabled by default**: Set `configReload.enabled: true` to activate this feature -- **Valid configs only**: Invalid configurations are rejected, and old config is preserved -- **No restart needed**: You can experiment with different configurations without restarting OpenCode -- **Session preservation**: Active sessions continue working during reload - -### ⚠️ Important: Configuration Required - -**As of v1.43.0, this plugin requires explicit configuration.** - -The default fallback models array is empty, meaning no fallback behavior will occur until you create a configuration file. +Do not install the 2.x entry point in a v1 host. The development dependencies pin the OpenCode plugin and SDK packages to `1.18.16`; that is **not** a claim of end-to-end compatibility with every v2/Web release. The v2 adapter has mocked hook tests; confirm your host supports its `prompt` and `retry` hooks before relying on it unattended. -**You must create a config file at one of these locations:** +## Quick start: v2 same-model waiting -**Config file search order (highest to lowest priority):** -1. `/.opencode/rate-limit-fallback.json` -2. `/rate-limit-fallback.json` -3. `/.opencode/rate-limit-fallback.json` -4. `/rate-limit-fallback.json` -5. `~/.opencode/rate-limit-fallback.json` (recommended for most users) -6. `~/.config/opencode/rate-limit-fallback.json` +Add the plugin to your **OpenCode configuration**, using the v2 tag rather than the v1 `latest` tag. After verifying a release, pin its exact version for reproducible installations. -> **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6). - -**If no config file is found, the plugin will:** -- Log a warning message -- Not perform any fallback operations -- Continue functioning normally with rate-limited models - -**Minimum working configuration:** ```json { - "fallbackModels": [ - { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20250514" } - ] + "plugins": ["@azumag/opencode-rate-limit-fallback@opencode-v2"] } ``` -## Migrating from v1.42.x or earlier - -### Breaking Change: Empty Default Models - -**What changed?** -- v1.43.0 removed the default fallback models -- You must now explicitly configure your fallback models -- The plugin will not work without a configuration file - -**Why was this changed?** -- To prevent unintended model usage (e.g., Gemini when not wanted) -- To make configuration errors obvious immediately -- To give users explicit control over which models to use +Create a **separate plugin configuration** at `~/.opencode/rate-limit-fallback.json`. Create the directory first if necessary. -### How to Migrate - -1. **Create a config file** at one of the locations listed above -2. **Add your desired fallback models** to the `fallbackModels` array -3. **Restart OpenCode** to load the new configuration - -### Example Migration - -**Before v1.43.0** (no config needed, used defaults): -``` -Plugin automatically used Claude and Gemini models as fallbacks -``` - -**After v1.43.0** (must create config): ```json { - "fallbackModels": [ - { "providerID": "anthropic", "modelID": "claude-3-5-sonnet-20250514" }, - { "providerID": "google", "modelID": "gemini-2.5-pro" } - ], - "enabled": true + "enabled": true, + "fallbackMode": "wait", + "cooldownMs": 60000, + "fallbackModels": [] } ``` -## Troubleshooting - -### "No fallback models configured" warning - -**Problem**: You see a warning about no fallback models configured. - -**Solution**: Create a config file with your desired fallback models. See the Configuration section above for details. +Restart the OpenCode process hosting the plugin. Select and authenticate your model through OpenCode itself. **Do not put API keys, cookies, passwords, or provider authentication in this plugin's JSON.** -### Plugin isn't falling back when rate limited +`fallbackMode: "wait"` must be explicit: the shared default is `"cycle"`, which makes the v2 adapter inactive. An empty model list is valid for `wait` because there is no fallback-model selection. -**Problem**: Rate limits occur but no fallback happens. +## v2 behavior and configuration -**Solutions**: -1. Check that a config file exists and is valid -2. Verify that `fallbackModels` is not empty in your config -3. Check that `enabled: true` is set in your config -4. Review logs for error messages +The adapter recognizes HTTP 429 and several rate/quota-related error phrases. It requests a retry of the existing session; it does not select another provider or model. -### "Config file not found" warning +With the example above, repeated matching errors produce delays of **60 seconds, 120 seconds, 240 seconds, …, 1 hour**. A new non-empty user prompt cancels the adapter's pending resume and resets its backoff. An automatic empty resume preserves the backoff. Success alone is not a reset signal in this adapter. -**Problem**: You see warnings about config file not being found. +| Setting | Behavior | +| --- | --- | +| `enabled` | `true` by default; `false` disables setup on the next host restart | +| `fallbackMode` | Must be `"wait"` for v2; other modes do not register hooks | +| `cooldownMs` | Initial delay, default `60000` ms; effective minimum `1000` ms | +| `fallbackModels` | Not used for model selection in v2; may be `[]` | -**Solution**: Create a config file at one of the recommended locations: +Starting in 2.0.3, `cooldownMs` must be a finite, non-negative JSON number no greater than `2147483397`. Invalid values reject plugin setup before hooks are registered, rather than producing unsafe timers. Values above one hour are preserved: the backoff ceiling is `max(3600000, cooldownMs)`, not always one hour. Prefer the 60-second example unless the provider requires a longer interval. -**Config file search order (highest to lowest priority):** -1. `/.opencode/rate-limit-fallback.json` -2. `/rate-limit-fallback.json` -3. `/.opencode/rate-limit-fallback.json` -4. `/rate-limit-fallback.json` -5. `~/.opencode/rate-limit-fallback.json` (recommended for most users) -6. `~/.config/opencode/rate-limit-fallback.json` +Starting in 2.0.3, explicit HTTP **401/403** errors stop this adapter's retries and cancel an already scheduled resume for that session, even if their messages mention quota. A provider error without those status fields still relies on text classification; false positives are possible. -> **Note**: Project-local and worktree configs (1-4) take precedence over global configs (5-6). +### Important limitations -### All models exhausted quickly +- There is intentionally **no total retry-count or elapsed-time limit** in `wait`. At the adapter's assumed client retry ceiling (attempt 5), it schedules an empty same-session resume after the delay plus 250 ms. This does not override a provider's server-side quota or guarantee eventual recovery. +- The adapter does **not parse `Retry-After` or quota-reset timestamps**, and has no jitter. Do not use it where an enforced provider retry interval cannot be respected. Avoid many concurrent waiting sessions. +- v1 options such as `retryPolicy`, circuit breakers, custom error patterns, metrics, dynamic model priorities, config hot reload, and `headlessOnRateLimit` are **not implemented by the v2 entry point**. Setting them does not provide a v2 safety limit. +- There are no v2 stop/deletion lifecycle hooks here. Do not assume a UI Stop button, session deletion, or closing a browser tab clears a server-side resume timer. To reliably stop this plugin's pending work, **terminate the OpenCode host process**, set `enabled: false`, and restart it. A new prompt cancels the previous adapter timer but also starts new work. -**Problem**: Fallback models are exhausted in a short time. +A delayed resume can continue an agent's permitted tool actions. Keep the host's tool permissions and approval requirements appropriate for work that might resume much later. -**Solutions**: -1. Add more fallback models to your config -2. Increase `cooldownMs` to allow models to recover -3. Consider using `fallbackMode: "cycle"` to reset automatically -4. Check your API rate limits +## Configuration lookup -## How It Works +The first readable, accepted configuration wins; files are not layered together. For the v2 entry point, the search order is: -1. **Detection**: The plugin listens for rate limit errors via: - - `session.error` events - - `message.updated` events with errors - - `session.status` events with `type: "retry"` +1. `/.opencode/rate-limit-fallback.json`, then `/rate-limit-fallback.json`. +2. `$HOME/.opencode/rate-limit-fallback.json`, then `$HOME/rate-limit-fallback.json`. +3. `$XDG_CONFIG_HOME/opencode/rate-limit-fallback.json`, then `$XDG_CONFIG_HOME/rate-limit-fallback.json`. When unset, `XDG_CONFIG_HOME` defaults to `$HOME/.config`. -2. **Abort**: When a rate limit is detected, the current session is aborted to stop OpenCode's internal retry mechanism. +The v2 adapter passes the same location for project and worktree. The v1 entry point can search a distinct worktree before the project directory. Project-local settings override global settings: inspect them before opening an untrusted repository. Changes require a host restart in v2. -3. **Fallback**: The plugin selects the next available model from the fallback list and resends the last user message. +## OpenCode Go, terms, costs, and privacy -4. **Cooldown**: Rate-limited models are tracked and skipped for the configured cooldown period. +Checked against the [Go documentation](https://opencode.ai/docs/go/) and [Terms of Service](https://opencode.ai/legal/terms-of-service) on **2026-09-14**; terms can change. -## Subagent Support +Go documents ordinary coding-agent use. The hosted-service terms also restrict account-based limit evasion, excessive load, and certain automated activity. **Neither this README nor a backoff implementation is permission for unrestricted unattended use.** Obtain clarification from the provider for an uncertain deployment; this project makes no legal-compliance guarantee. Its MIT license covers the plugin code, not access to hosted models. -When OpenCode uses subagents (e.g., for complex tasks requiring specialized agents): +Use only authorized accounts and supported host requests. Do not rotate accounts or spoof client/session identity to evade limits. This plugin delegates requests to OpenCode rather than constructing alternative provider requests; the host remains responsible for its client and session headers. -- **Automatic Detection**: The plugin detects child `session.created` events through `info.parentID` -- **Hierarchy Tracking**: Maintains parent-child relationships between sessions -- **Targeted Retry**: When a subagent hits a rate limit, only that child session is retried with the next model and the same agent -- **Independent State**: Parent and sibling sessions keep their own active models and retry state +The Go **Use balance** option can consume Zen credits after subscription limits. Check billing settings before enabling automatic resume. Model-specific retention and training policies also differ; do not assume all Go models have identical privacy guarantees. In v1, fallback can resend conversation content and attachments to a **different provider**, with different costs and policies. -### Subagent Configuration +## Data handling and safe reporting -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `maxSubagentDepth` | number | `10` | Maximum nesting depth for subagent hierarchies | -| `enableSubagentFallback` | boolean | `true` | Enable/disable fallback for subagent sessions | +The v2 adapter itself does not read provider credentials, export conversation logs, or call an independent telemetry endpoint. It reads local plugin configuration and holds session IDs, retry counters, and timers in memory. Resumed work still goes through OpenCode and the configured model provider: **this is not offline processing or a promise about the host's logging**. -Set `enableSubagentFallback` to `true` when you want child sessions created by -`Task` or custom subagents to use the fallback list. With `false`, rate-limit -events from tracked child sessions are left to OpenCode. +Legacy v1 features can write model-health statistics locally, export optional metrics, persist learned error patterns to configuration, and send diagnostics to OpenCode's application log. Diagnostic metadata can include paths, session IDs, configuration values, and provider errors; it is not a comprehensive secret-redaction system. -## Logging +Before sharing an issue, review and redact logs, configuration, screenshots, test output, and commit metadata. Never upload `.env`, `auth.json`, token-bearing `.npmrc`, private keys, or real conversations. `.gitignore` helps prevent future accidental additions; it cannot remove already committed data from Git history, old PRs, published packages, or cached copies. A clean automated secret scan is useful evidence, not a guarantee that all personal information is absent. -Plugin diagnostics are sent through [OpenCode's structured application log API](https://opencode.ai/docs/plugins/#logging). The plugin does not write diagnostics with `console.*`, so enabling `info` logging does not inject messages into the TUI console overlay. +## Using OpenCode v1 -## Metrics - - The plugin includes a metrics collection feature that tracks: - - Rate limit events per provider/model - - Fallback statistics (total, successful, failed, average duration) - - **Retry statistics** (total attempts, successes, failures, average delay) - - Model performance (requests, successes, failures, response time) - - **Circuit breaker statistics** (state transitions, open/closed counts) - - **Dynamic prioritization statistics** (enabled status, reorder count, models with scores) - - **Pattern learning statistics** (processed, learned, rejected, persistence failures, average confidence, learned-pattern matches) - -### Metrics Configuration - -Metrics can be configured via the `metrics` section in your config file: +Keep the v1 package line: ```json { - "metrics": { - "enabled": true, - "output": { - "console": true, - "file": "/path/to/metrics.json", - "format": "pretty" - }, - "resetInterval": "daily" - } + "plugin": ["@azumag/opencode-rate-limit-fallback@1.70.11"] } ``` -### Metrics Options +The same minimal `wait` configuration above works with the v1 implementation, but its waiting interval is fixed rather than exponentially increasing. See the [v1 reference](docs/v1-reference.md) and [v1 quota-wait behavior](docs/quota-wait-mode.md) before configuring model switching or headless execution. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `enabled` | boolean | `false` | Enable/disable metrics collection | -| `output.console` | boolean | `true` | Send metrics reports to the OpenCode application log | -| `output.file` | string | `undefined` | Path to save metrics file | -| `output.format` | string | `"pretty"` | Output format: `"pretty"`, `"json"`, or `"csv"` | -| `resetInterval` | string | `"daily"` | Reset interval: `"hourly"`, `"daily"`, or `"weekly"` | +## Development and verification -### Output Formats +The repository CI uses Node.js 24. From a trusted checkout: -**Pretty** (human-readable): +```sh +npm ci +npm run typecheck +npm test +npm run build +npm pack --dry-run --ignore-scripts ``` -============================================================ -Rate Limit Fallback Metrics -============================================================ -Started: 2025-02-10T02:00:00.000Z -Generated: 2025-02-10T02:30:00.000Z - -Rate Limits: ----------------------------------------- - anthropic/claude-3-5-sonnet-20250514: - Count: 5 - First: 2025-02-10T02:00:00.000Z - Last: 2025-02-10T02:29:00.000Z - Avg Interval: 3.50s - -Fallbacks: ----------------------------------------- - Total: 3 - Successful: 2 - Failed: 1 - Avg Duration: 1.25s - -Retries: ----------------------------------------- - Total: 12 - Successful: 8 - Failed: 4 - Avg Delay: 2.5s - - By Model: - anthropic/claude-3-5-sonnet-20250514: - Attempts: 5 - Successes: 3 - Success Rate: 60.0% - google/gemini-2.5-pro: - Attempts: 7 - Successes: 5 - Success Rate: 71.4% - -Model Performance: ----------------------------------------- - google/gemini-2.5-pro: - Requests: 10 - Successes: 9 - Failures: 1 - Avg Response: 0.85s - Success Rate: 90.0% - - Circuit Breaker: - ---------------------------------------- - anthropic/claude-3-5-sonnet-20250514: - State: OPEN - Failures: 5 - Successes: 0 - State Transitions: 2 - google/gemini-2.5-pro: - State: CLOSED - Failures: 2 - Successes: 8 - State Transitions: 3 - - Dynamic Prioritization: - ---------------------------------------- - Enabled: Yes - Reorders: 5 - Models with dynamic scores: 3 - ``` - - **JSON** (machine-readable): -```json -{ - "rateLimits": { - "anthropic/claude-3-5-sonnet-20250514": { - "count": 5, - "firstOccurrence": 1739148000000, - "lastOccurrence": 1739149740000, - "averageInterval": 3500 - } - }, - "fallbacks": { - "total": 3, - "successful": 2, - "failed": 1, - "averageDuration": 1250, - "byTargetModel": { - "google/gemini-2.5-pro": { - "usedAsFallback": 2, - "successful": 2, - "failed": 0 - } - } - }, - "retries": { - "total": 12, - "successful": 8, - "failed": 4, - "averageDelay": 2500, - "byModel": { - "anthropic/claude-3-5-sonnet-20250514": { - "attempts": 5, - "successes": 3 - }, - "google/gemini-2.5-pro": { - "attempts": 7, - "successes": 5 - } - } - }, - "modelPerformance": { - "google/gemini-2.5-pro": { - "requests": 10, - "successes": 9, - "failures": 1, - "averageResponseTime": 850 - } - }, - "circuitBreaker": { - "anthropic/claude-3-5-sonnet-20250514": { - "currentState": "OPEN", - "failures": 5, - "successes": 0, - "stateTransitions": 2 - }, - "google/gemini-2.5-pro": { - "currentState": "CLOSED", - "failures": 2, - "successes": 8, - "stateTransitions": 3 - } - }, - "dynamicPrioritization": { - "enabled": true, - "reorders": 5, - "modelsWithDynamicScores": 3 - }, - "startedAt": 1739148000000, - "generatedAt": 1739149800000 - } - ``` - -**CSV** (spreadsheet-friendly): -``` -=== RATE_LIMITS === -model,count,first_occurrence,last_occurrence,avg_interval_ms -anthropic/claude-3-5-sonnet-20250514,5,1739148000000,1739149740000,3500 - -=== FALLBACKS_SUMMARY === -total,successful,failed,avg_duration_ms -3,2,1,1250 - -=== RETRIES_SUMMARY === -total,successful,failed,avg_delay_ms -12,8,4,2500 - -=== RETRIES_BY_MODEL === -model,attempts,successes,success_rate -anthropic/claude-3-5-sonnet-20250514,5,3,60.0 -google/gemini-2.5-pro,7,5,71.4 - - === MODEL_PERFORMANCE === - model,requests,successes,failures,avg_response_time_ms,success_rate - google/gemini-2.5-pro,10,9,1,850,90.0 - === CIRCUIT_BREAKER === - model,current_state,failures,successes,state_transitions - anthropic/claude-3-5-sonnet-20250514,OPEN,5,0,2 - google/gemini-2.5-pro,CLOSED,2,8,3 +Tests use mocks and synthetic inputs; they do not prove provider approval, live quota-reset behavior, or every host's lifecycle behavior. The publication-review workflow scans reachable Git history and tracked files with redacted Gitleaks results. Its summary lists exclusions; deleted/unreachable objects, external copies, and all hosted discussion/log surfaces are not covered by that scan. - === DYNAMIC_PRIORITIZATION === - enabled,reorders,models_with_dynamic_scores - Yes,5,3 - ``` +English and Japanese READMEs should be updated together. Do not commit real credentials or production conversations as test fixtures. ## License -MIT +[MIT](LICENSE). OpenCode and provider names identify compatibility targets, not endorsement. diff --git a/__tests__/v2.test.ts b/__tests__/v2.test.ts index e42b864..d0567b4 100644 --- a/__tests__/v2.test.ts +++ b/__tests__/v2.test.ts @@ -149,4 +149,100 @@ describe("OpenCode v2 plugin", () => { await cleanup?.(); }); + + it.each(['"invalid"', '"60000"', 'null', 'true', '[]', '{}', '-1', '1e309', '2147483398']) + ("rejects unsafe cooldown %s before registering hooks", async (rawCooldown) => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + `{"enabled":true,"fallbackMode":"wait","cooldownMs":${rawCooldown}}`, + ); + const hook = vi.fn(); + const prompt = vi.fn(); + await expect(plugin.setup({ + location: { directory: "/test" }, + session: { hook, prompt }, + })).rejects.toThrow("cooldownMs must be a finite non-negative number"); + expect(hook).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + }); + + it.each([ + [0, 1000], + [1000, 1000], + [7200000, 7200000], + [2147483397, 2147483397], + ])("keeps cooldown %s safe through the terminal resume timer", async (input, expected) => { + vi.useFakeTimers(); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, fallbackMode: "wait", cooldownMs: input, + })); + let retryHook: ((event: any) => void) | undefined; + const prompt = vi.fn().mockResolvedValue(undefined); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (name, callback) => { + if (name === "retry") retryHook = callback; + return { dispose: vi.fn() }; + }), + prompt, + }, + }); + const event = { + sessionID: "session-1", attempt: 5, + error: { status: 429, message: "Rate limit exceeded" }, + decision: { retry: false }, + }; + retryHook?.(event); + expect(event.decision).toEqual({ retry: true, delay: expected }); + await vi.advanceTimersByTimeAsync(expected + 249); + expect(prompt).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(prompt).toHaveBeenCalledOnce(); + await cleanup?.(); + }); + + it.each([401, 403])("stops HTTP %s even when the message mentions quota", async (status) => { + vi.useFakeTimers(); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, fallbackMode: "wait", cooldownMs: 1000, + })); + let retryHook: ((event: any) => void) | undefined; + const prompt = vi.fn().mockResolvedValue(undefined); + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (name, callback) => { + if (name === "retry") retryHook = callback; + return { dispose: vi.fn() }; + }), + prompt, + }, + }); + retryHook?.({ + sessionID: "session-1", attempt: 5, + error: { status: 429, message: "quota exceeded" }, decision: { retry: false }, + }); + expect(vi.getTimerCount()).toBe(1); + const denied = { + sessionID: "session-1", attempt: 6, + error: { status, message: "Account disabled: quota access revoked" }, + decision: { retry: true, delay: 1000 }, + }; + retryHook?.(denied); + expect(denied.decision).toEqual({ retry: false }); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5000); + expect(prompt).not.toHaveBeenCalled(); + const next = { + sessionID: "session-1", attempt: 1, + error: { status: 429 }, decision: { retry: false }, + }; + retryHook?.(next); + expect(next.decision).toEqual({ retry: true, delay: 1000 }); + await cleanup?.(); + }); + }); diff --git a/docs/quota-wait-mode.md b/docs/quota-wait-mode.md index 669f89b..687cb32 100644 --- a/docs/quota-wait-mode.md +++ b/docs/quota-wait-mode.md @@ -1,6 +1,11 @@ -# Single-model quota wait mode +# v1: single-model quota wait mode -`fallbackMode: "wait"` is the simplest unattended rate-limit mode. It never +[English overview](../README.md) | [日本語概要](../README.ja.md) + +**This page applies only to the 1.x implementation.** The 2.x adapter uses +exponential backoff and has different lifecycle limitations; see the main README. + +`fallbackMode: "wait"` keeps the current model while waiting for a rate limit to clear. It never switches models. When the active model hits a recognized rate/quota limit, the plugin aborts the current server retry loop, waits for `cooldownMs`, and retries the same user message with the same model and OpenCode agent. @@ -53,8 +58,8 @@ quota wait is explicitly intended to survive long quota-reset windows. ## Current scope - Only errors already classified by the plugin as rate/quota limits enter this - loop. Authentication, invalid-request, missing-model, and unrelated server - errors do not. + loop. Classification can be imperfect; do not assume every error mentioning + quota is recoverable. The v2 HTTP 401/403 safeguard is not a v1 backport. - The first version uses `cooldownMs` as a fixed polling interval. Provider `Retry-After` / quota-reset timestamps are not parsed yet. - OpenCode headless mode (`opencode run`) is supported. An explicit @@ -64,3 +69,8 @@ quota wait is explicitly intended to survive long quota-reset windows. This mode is intentionally independent from a future multi-model behavior such as `A -> B -> wait -> A -> B`. + +Waiting can resume the agent's permitted actions much later. Retain appropriate +tool approvals, respect provider retry instructions, and check billing settings. +This feature does not bypass server-side quotas or grant permission for +unrestricted automated use. See the terms and privacy sections in the main README. diff --git a/docs/v1-reference.md b/docs/v1-reference.md new file mode 100644 index 0000000..58980cc --- /dev/null +++ b/docs/v1-reference.md @@ -0,0 +1,87 @@ +# Legacy v1 reference + +[English overview](../README.md) | [日本語概要](../README.ja.md) + +**This page applies to the 1.x plugin, not the 2.x entry point.** Install the documented v1 pin with OpenCode's singular `plugin` key: + +```json +{ + "plugin": ["@azumag/opencode-rate-limit-fallback@1.70.11"] +} +``` + +The v2 fixes described in the main README are not automatically backported to a published 1.x package. Its development SDK version is not an end-to-end compatibility guarantee. + +## Single-model waiting + +Use a separate `rate-limit-fallback.json`: + +```json +{ + "enabled": true, + "fallbackMode": "wait", + "cooldownMs": 60000, + "fallbackModels": [] +} +``` + +This mode waits at a fixed interval and preserves the active model and agent. It intentionally ignores the model-fallback retry count and timeout. See [v1 quota-wait details](quota-wait-mode.md). Use finite numeric settings and retain appropriate tool approvals for delayed work. + +## Model switching + +For model switching, configure a non-empty `fallbackModels` list with the exact `providerID` and `modelID` reported by your authenticated OpenCode installation. No default usable fallback models are supplied. The identifiers below are placeholders, **not a runnable model catalogue**: + +```json +{ + "enabled": true, + "fallbackMode": "stop", + "cooldownMs": 60000, + "fallbackModels": [ + { "providerID": "your-provider", "modelID": "your-fallback-model" } + ] +} +``` + +When a recognized rate limit occurs, v1 aborts the active request and attempts the configured fallback, preserving the active agent. A different provider can receive conversation content or attachments; authorize every destination and review its billing and privacy terms first. + +| Mode | Exhaustion behavior | +| --- | --- | +| `cycle` (shared default) | Restart from the first fallback model | +| `stop` | Stop rather than cycling through the list again | +| `retry-last` | Try the last model once more; reset on the next prompt | +| `wait` | Bypass model selection and wait for the current model indefinitely | + +## Configuration and optional features + +A distinct worktree is searched before the project; each checks `.opencode/rate-limit-fallback.json` before its root-level file. Home and XDG locations follow as described in the main README. The first accepted file wins. Inspect repository-local settings before use. + +| Setting group | Purpose / defaults worth knowing | +| --- | --- | +| `retryPolicy` | Model-fallback retry strategy; defaults include `maxRetries: 3`, `strategy: "immediate"`, `baseDelayMs: 1000`, `maxDelayMs: 30000`, `jitterEnabled: false`. Not a bound on `wait`. | +| `circuitBreaker` | Optional failure isolation and recovery; disabled by default. | +| `healthPersistence` | Model-health state; enabled by default and stored under `~/.opencode/rate-limit-fallback-health.json`. | +| `metrics` | Optional metrics with console/file output and pretty/JSON/CSV formats; disabled by default. | +| `configReload` | Optional watching and configuration reload; v2 does not implement this. | +| `dynamicPrioritization` | Optional ordering based on success rate, response time and recent usage. | +| `errorPatterns` | Custom matching and false-positive ignores; built-in benign billing notices are ignored unless stronger rate-limit signals exist. | +| `errorPatterns.enableLearning` | Disabled by default; learns extracted rate-limit phrases/codes and writes them to configuration, rather than saving entire raw error bodies. | +| `enableSubagentFallback`, `maxSubagentDepth` | Subagent handling is enabled by default; default nesting depth is 10. | +| `log` | Logging defaults to warnings. Diagnostics are not comprehensively redacted. | + +JSON configurations cannot supply functions or actual `RegExp` objects. Use supported JSON values; advanced programmatic interfaces are a separate concern. + +For the earlier long-form explanations and illustrative metrics/configuration examples, consult the [archived README at the pre-review commit](https://github.com/azumag/opencode-rate-limit-fallback/blob/74b6b9dbcfc56fc963a78d0fa025e781ca410dd7/README.md). That snapshot mixes release lines and includes historical model examples; use this page and the current language-specific README for installation and version scope, not the archived v2 quick start. + +## Headless execution + +For `opencode run`, v1 model switching is disabled by default. An unset `headlessOnRateLimit` or `"ignore"` leaves handling to the host. `"abort"` requests immediate abort on a recognized rate limit. + +`wait` is the same-model exception: it operates in headless mode unless `headlessOnRateLimit` is explicitly `"abort"`. These v1 settings do not add headless controls to v2. + +## Privacy, terms, and troubleshooting + +Health statistics, optional metric exports, learned patterns, and application logs are separate surfaces. Do not publish raw error bodies, user messages, session identifiers, configuration secrets, or machine-specific paths. A project-local fallback configuration can redirect work to another provider. + +Read the terms/cost/privacy and data-handling sections in the [English README](../README.md) or [日本語README](../README.ja.md). Neither changing models nor waiting grants an exception to a provider's quotas or policies. The v1 wait loop also does not parse provider retry/reset headers. + +When the plugin appears inactive, verify the host/package release line, singular `plugin` key, winning configuration file, `enabled`, configured models (unless `wait`), and headless behavior before enabling verbose diagnostics. Sanitize any diagnostic output before sharing it. diff --git a/package-lock.json b/package-lock.json index 8452e7f..6cb7530 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.2", + "version": "2.0.3", "license": "MIT", "devDependencies": { "@opencode-ai/plugin": "1.18.16", diff --git a/package.json b/package.json index a9a6825..b7e0523 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.2", - "description": "OpenCode plugin that automatically switches to fallback models when rate limited", + "version": "2.0.3", + "description": "OpenCode plugin for same-model quota waiting and legacy model fallback", "main": "dist/v2.js", "types": "dist/v2.d.ts", "exports": { @@ -41,6 +41,9 @@ "dist/**/*.js", "dist/**/*.d.ts", "README.md", + "README.ja.md", + "docs/v1-reference.md", + "docs/quota-wait-mode.md", "LICENSE" ], "devDependencies": { diff --git a/v2.ts b/v2.ts index 7ccfd5c..5b2a77f 100644 --- a/v2.ts +++ b/v2.ts @@ -40,6 +40,9 @@ type V2Context = { }; const MAX_BACKOFF_MS = 60 * 60 * 1000; +const RESUME_PADDING_MS = 250; +// Keep the resumed timeout within the signed 32-bit timer limit. +const MAX_COOLDOWN_MS = 2 ** 31 - 1 - RESUME_PADDING_MS; const RATE_LIMIT_PATTERNS = [ /\b429\b/i, @@ -59,8 +62,17 @@ function isRateLimit(value: unknown): boolean { return RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(text)); } -function effectiveCooldown(cooldownMs: number | undefined): number { - return Math.max(1000, cooldownMs ?? 60000); +function effectiveCooldown(cooldownMs: unknown): number { + const value = cooldownMs === undefined ? 60000 : cooldownMs; + // JSON configuration is untrusted at runtime, regardless of its TS type. + // NaN and overflowing timeouts can otherwise become near-immediate retries. + if (typeof value !== "number" || !Number.isFinite(value) || + value < 0 || value > MAX_COOLDOWN_MS) { + throw new RangeError( + `cooldownMs must be a finite non-negative number no greater than ${MAX_COOLDOWN_MS}.`, + ); + } + return Math.max(1000, value); } function exponentialDelay(baseDelayMs: number, failureCount: number): number { @@ -95,7 +107,7 @@ export default { void context.session.prompt({ sessionID, text: "", resume: true }).catch(() => { // A deleted or otherwise unavailable session must not keep retrying. }); - }, delay + 250); + }, delay + RESUME_PADDING_MS); resumeTimers.set(sessionID, timer); }; @@ -106,6 +118,13 @@ export default { }); const retryRegistration = await context.session.hook("retry", (event) => { + // A permission failure is not a quota reset. Also cancel a resume that + // may already have been scheduled by an earlier rate-limit event. + if (event.error?.status === 401 || event.error?.status === 403) { + clearSessionState(event.sessionID); + event.decision = { retry: false }; + return; + } if (!isRateLimit(event.error)) return; const previous = resumeTimers.get(event.sessionID); if (previous) {