miawrouter 1.2.3 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/app/.next-cli-build/BUILD_ID +1 -1
- package/app/.next-cli-build/app-path-routes-manifest.json +9 -9
- package/app/.next-cli-build/build-manifest.json +2 -2
- package/app/.next-cli-build/prerender-manifest.json +3 -3
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/a2a/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/a2a/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/activity/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/activity/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-skills/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/agent-skills/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/api-endpoints/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/api-endpoints/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/basic-chat/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/batch/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/batch/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cache/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cache/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/[toolId]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cloud-agents/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/cloud-agents/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/console-log/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/costs/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/costs/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/discovery/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/discovery/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-provider-rankings/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-provider-rankings/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-tiers/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/free-tiers/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/guardrails/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/guardrails/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/health/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/health/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/logs/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/logs/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/mcp/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/mcp/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/[kind]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/combo/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/media-providers/web/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/memory/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/memory/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/mitm/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/playground/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/playground/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/privacy/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/privacy/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/provider-stats/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/provider-stats/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/proxy-pools/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/proxy-pools/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/pxpipe/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/pxpipe/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/quota/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/runtime/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/runtime/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/feature-flags/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/feature-flags/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/pricing/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skill-discovery/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skill-discovery/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/skills/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/token-saver/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/webhooks/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/(dashboard)/dashboard/webhooks/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/.well-known/agent.json/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/_global-error/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/_global-error.html +1 -1
- package/app/.next-cli-build/server/app/_global-error.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/app/.next-cli-build/server/app/_not-found/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/_not-found.html +1 -1
- package/app/.next-cli-build/server/app/_not-found.rsc +5 -5
- package/app/.next-cli-build/server/app/_not-found.segments/_full.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/api/a2a/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/a2a/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/agents/[id]/actions/route.js +1 -1
- package/app/.next-cli-build/server/app/api/agents/[id]/actions/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/agents/route.js +1 -1
- package/app/.next-cli-build/server/app/api/agents/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/login/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/oidc/callback/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/oidc/start/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/oidc/test/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/reset-password/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/auth/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batch/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batch/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/[id]/cancel/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/[id]/errors/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/[id]/results/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/[id]/retry/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/batches/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cache/clear/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cache/stats/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/catalog/opencode/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js +5 -5
- package/app/.next-cli-build/server/app/api/cli-tools/all-statuses/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/alias/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/antigravity-mitm/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/claude-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/cline-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/codex-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/copilot-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/copilot-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-registry/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-mcp-tools/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/cowork-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js +3 -3
- package/app/.next-cli-build/server/app/api/cli-tools/deepseek-tui-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/devin-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/devin-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/droid-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/grok-build-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/grok-build-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/hermes-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/jcode-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/kilo-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/oh-my-pi-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/oh-my-pi-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/openclaw-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js +2 -2
- package/app/.next-cli-build/server/app/api/cli-tools/opencode-settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/subagents/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cli-tools/subagents/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/cloud-agents/status/route.js +1 -1
- package/app/.next-cli-build/server/app/api/cloud-agents/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/combos/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/combos/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/dashboard/chat/completions/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/discovery/endpoints/route.js +1 -1
- package/app/.next-cli-build/server/app/api/discovery/endpoints/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/discovery/route.js +1 -1
- package/app/.next-cli-build/server/app/api/discovery/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/discovery/skills/route.js +1 -1
- package/app/.next-cli-build/server/app/api/discovery/skills/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/events/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/extras/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/proxy/[...path]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/restart/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/start/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/headroom/stop/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/health/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/hermes/autonomy/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/init/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/keys/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/keys/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/locale/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/mcp/[plugin]/message/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/mcp/[plugin]/sse/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/mcp/route.js +1 -1
- package/app/.next-cli-build/server/app/api/mcp/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/mcp/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/media-providers/tts/deepgram/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/media-providers/tts/elevenlabs/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/media-providers/tts/inworld/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/media-providers/tts/minimax/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/media-providers/tts/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/memory/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/memory/health/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/memory/reindex/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/memory/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/memory/search/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/metrics/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/alias/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/availability/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/custom/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/disabled/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/models/test/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/[provider]/[action]/route.js +1 -1
- package/app/.next-cli-build/server/app/api/oauth/[provider]/[action]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/codex/bulk-import/route.js +1 -1
- package/app/.next-cli-build/server/app/api/oauth/codex/bulk-import/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/codex/import-token/route.js +1 -1
- package/app/.next-cli-build/server/app/api/oauth/codex/import-token/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/cursor/auto-import/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/cursor/import/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/gitlab/pat/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/iflow/cookie/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/api-key/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/auto-import/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/import/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/import-cli-proxy/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/social-authorize/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/oauth/kiro/social-exchange/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pricing/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/provider-nodes/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/provider-nodes/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/provider-nodes/validate/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/export/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/onboard/route.js +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/onboard/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/test/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/[id]/test-models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/client/route.js +1 -1
- package/app/.next-cli-build/server/app/api/providers/client/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/kilo/free-models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/suggested-models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/test-batch/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/providers/validate/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/[id]/test/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/cloudflare-deploy/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/deno-deploy/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/vercel-deploy/route.js +1 -1
- package/app/.next-cli-build/server/app/api/proxy-pools/vercel-deploy/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/health/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/install/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/logs/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/restart/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/start/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/stats/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/pxpipe/stop/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/saver/stats/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/settings/database/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/settings/proxy-test/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/settings/require-login/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/settings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/shutdown/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/skill-discovery/[id]/install/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/skill-discovery/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/skill-discovery/[id]/uninstall/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/skill-discovery/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/skill-discovery/sync/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tags/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/console-logs/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/console-logs/stream/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/load/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/save/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/send/route.js +1 -1
- package/app/.next-cli-build/server/app/api/translator/send/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/translator/translate/route.js +1 -1
- package/app/.next-cli-build/server/app/api/translator/translate/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/disable/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/enable/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/status/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/tailscale-check/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/tailscale-disable/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/tailscale-enable/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/tunnel/tailscale-install/route.js +2 -2
- package/app/.next-cli-build/server/app/api/tunnel/tailscale-install/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/[connectionId]/codex-reset-credits/route.js +1 -1
- package/app/.next-cli-build/server/app/api/usage/[connectionId]/codex-reset-credits/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/[connectionId]/route.js +1 -1
- package/app/.next-cli-build/server/app/api/usage/[connectionId]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/chart/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/history/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/local-snapshot/route.js +1 -1
- package/app/.next-cli-build/server/app/api/usage/local-snapshot/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/logs/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/providers/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/request-details/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/request-logs/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/stats/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/usage/stream/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/api/chat/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/audio/speech/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/audio/transcriptions/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/audio/voices/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/chat/completions/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/embeddings/route.js +1 -1
- package/app/.next-cli-build/server/app/api/v1/embeddings/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/images/generations/route.js +1 -1
- package/app/.next-cli-build/server/app/api/v1/images/generations/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/messages/count_tokens/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/messages/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/models/[kind]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/models/info/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/responses/compact/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/responses/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/search/route.js +1 -1
- package/app/.next-cli-build/server/app/api/v1/search/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/videos/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/videos/edits/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/videos/extensions/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/videos/generations/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1/web/fetch/route.js +1 -1
- package/app/.next-cli-build/server/app/api/v1/web/fetch/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1beta/models/[...path]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/v1beta/models/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/version/route.js +1 -1
- package/app/.next-cli-build/server/app/api/version/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/version/shutdown/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/version/update/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/webhooks/[id]/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/webhooks/events/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/api/webhooks/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/callback/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/callback/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/callback.html +1 -1
- package/app/.next-cli-build/server/app/callback.rsc +5 -5
- package/app/.next-cli-build/server/app/callback.segments/_full.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/callback.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/callback.segments/callback/__PAGE__.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/dashboard/a2a.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/a2a.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/a2a.segments/!KGRhc2hib2FyZCk/dashboard/a2a/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/a2a.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/a2a.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/activity.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/activity.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/activity.segments/!KGRhc2hib2FyZCk/dashboard/activity/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/activity.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/activity.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/agent-skills.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/agent-skills.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/!KGRhc2hib2FyZCk/dashboard/agent-skills/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/agent-skills.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/api-endpoints.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/api-endpoints.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/!KGRhc2hib2FyZCk/dashboard/api-endpoints/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/api-endpoints.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/basic-chat.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/basic-chat.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/!KGRhc2hib2FyZCk/dashboard/basic-chat/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/basic-chat.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/batch.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/batch.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/batch.segments/!KGRhc2hib2FyZCk/dashboard/batch/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/batch.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/batch.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/cache.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/cache.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cache.segments/!KGRhc2hib2FyZCk/dashboard/cache/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cache.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cache.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/cli-tools.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/cli-tools.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/!KGRhc2hib2FyZCk/dashboard/cli-tools/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cli-tools.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/cloud-agents.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/cloud-agents.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/!KGRhc2hib2FyZCk/dashboard/cloud-agents/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/cloud-agents.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/combos.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/combos.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/combos.segments/!KGRhc2hib2FyZCk/dashboard/combos/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/combos.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/combos.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/costs.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/costs.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/costs.segments/!KGRhc2hib2FyZCk/dashboard/costs/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/costs.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/costs.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/discovery.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/discovery.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/discovery.segments/!KGRhc2hib2FyZCk/dashboard/discovery/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/discovery.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/discovery.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/endpoint.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/endpoint.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/endpoint.segments/!KGRhc2hib2FyZCk/dashboard/endpoint/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/endpoint.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/!KGRhc2hib2FyZCk/dashboard/free-provider-rankings/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-provider-rankings.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/free-tiers.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/free-tiers.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/!KGRhc2hib2FyZCk/dashboard/free-tiers/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/free-tiers.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/guardrails.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/guardrails.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/guardrails.segments/!KGRhc2hib2FyZCk/dashboard/guardrails/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/guardrails.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/guardrails.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/health.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/health.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/health.segments/!KGRhc2hib2FyZCk/dashboard/health/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/health.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/health.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/logs.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/logs.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/logs.segments/!KGRhc2hib2FyZCk/dashboard/logs/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/logs.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/logs.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/mcp.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/mcp.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mcp.segments/!KGRhc2hib2FyZCk/dashboard/mcp/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mcp.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mcp.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/media-providers/web.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/media-providers/web.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/!KGRhc2hib2FyZCk/dashboard/media-providers/web/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/media-providers/web.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/memory.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/memory.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/memory.segments/!KGRhc2hib2FyZCk/dashboard/memory/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/memory.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/memory.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/mitm.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/mitm.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mitm.segments/!KGRhc2hib2FyZCk/dashboard/mitm/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mitm.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/mitm.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/playground.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/playground.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/playground.segments/!KGRhc2hib2FyZCk/dashboard/playground/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/playground.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/playground.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/privacy.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/privacy.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/privacy.segments/!KGRhc2hib2FyZCk/dashboard/privacy/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/privacy.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/privacy.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/profile.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/profile.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/profile.segments/!KGRhc2hib2FyZCk/dashboard/profile/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/profile.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/profile.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/provider-stats.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/provider-stats.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/!KGRhc2hib2FyZCk/dashboard/provider-stats/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/provider-stats.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/providers/new.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/providers/new.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers/new.segments/!KGRhc2hib2FyZCk/dashboard/providers/new/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers/new.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/providers.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/providers.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers.segments/!KGRhc2hib2FyZCk/dashboard/providers/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/providers.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/proxy-pools.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/proxy-pools.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/!KGRhc2hib2FyZCk/dashboard/proxy-pools/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/proxy-pools.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/pxpipe.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/pxpipe.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/!KGRhc2hib2FyZCk/dashboard/pxpipe/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/pxpipe.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/quota.html +2 -2
- package/app/.next-cli-build/server/app/dashboard/quota.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/quota.segments/!KGRhc2hib2FyZCk/dashboard/quota/__PAGE__.segment.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/quota.segments/_full.segment.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/quota.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/runtime.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/runtime.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/runtime.segments/!KGRhc2hib2FyZCk/dashboard/runtime/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/runtime.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/runtime.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/!KGRhc2hib2FyZCk/dashboard/settings/feature-flags/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/feature-flags.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/!KGRhc2hib2FyZCk/dashboard/settings/pricing/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/settings/pricing.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/skill-discovery.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/skill-discovery.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/!KGRhc2hib2FyZCk/dashboard/skill-discovery/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skill-discovery.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/skills.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/skills.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skills.segments/!KGRhc2hib2FyZCk/dashboard/skills/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skills.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/skills.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/token-saver.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/token-saver.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/token-saver.segments/!KGRhc2hib2FyZCk/dashboard/token-saver/__PAGE__.segment.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_full.segment.rsc +8 -8
- package/app/.next-cli-build/server/app/dashboard/token-saver.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/translator.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/translator.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/translator.segments/!KGRhc2hib2FyZCk/dashboard/translator/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/translator.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/translator.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/usage.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/usage.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/usage.segments/!KGRhc2hib2FyZCk/dashboard/usage/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/usage.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/usage.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard/webhooks.html +1 -1
- package/app/.next-cli-build/server/app/dashboard/webhooks.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/webhooks.segments/!KGRhc2hib2FyZCk/dashboard/webhooks/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/webhooks.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard/webhooks.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/dashboard.html +1 -1
- package/app/.next-cli-build/server/app/dashboard.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard.segments/!KGRhc2hib2FyZCk/dashboard/__PAGE__.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard.segments/_full.segment.rsc +7 -7
- package/app/.next-cli-build/server/app/dashboard.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/favicon.ico/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/index.html +1 -1
- package/app/.next-cli-build/server/app/index.rsc +5 -5
- package/app/.next-cli-build/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/index.segments/_full.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/index.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/landing/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/landing/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/landing.html +1 -1
- package/app/.next-cli-build/server/app/landing.rsc +5 -5
- package/app/.next-cli-build/server/app/landing.segments/_full.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/landing.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/landing.segments/landing/__PAGE__.segment.rsc +5 -5
- package/app/.next-cli-build/server/app/login/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/login/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app/login.html +1 -1
- package/app/.next-cli-build/server/app/login.rsc +6 -6
- package/app/.next-cli-build/server/app/login.segments/_full.segment.rsc +6 -6
- package/app/.next-cli-build/server/app/login.segments/_tree.segment.rsc +2 -2
- package/app/.next-cli-build/server/app/login.segments/login/__PAGE__.segment.rsc +6 -6
- package/app/.next-cli-build/server/app/manifest.webmanifest/route.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/page.js.nft.json +1 -1
- package/app/.next-cli-build/server/app/page_client-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/app-paths-manifest.json +9 -9
- package/app/.next-cli-build/server/chunks/112.js +1 -1
- package/app/.next-cli-build/server/chunks/2638.js +1 -1
- package/app/.next-cli-build/server/chunks/2813.js +1 -1
- package/app/.next-cli-build/server/chunks/2974.js +1 -1
- package/app/.next-cli-build/server/chunks/318.js +1 -1
- package/app/.next-cli-build/server/chunks/3755.js +1 -1
- package/app/.next-cli-build/server/chunks/4037.js +1 -1
- package/app/.next-cli-build/server/chunks/4303.js +1 -1
- package/app/.next-cli-build/server/chunks/5243.js +1 -1
- package/app/.next-cli-build/server/chunks/5731.js +1 -1
- package/app/.next-cli-build/server/chunks/6270.js +1 -1
- package/app/.next-cli-build/server/chunks/6664.js +1 -1
- package/app/.next-cli-build/server/chunks/7403.js +1 -1
- package/app/.next-cli-build/server/chunks/772.js +1 -1
- package/app/.next-cli-build/server/chunks/{4018.js → 7937.js} +5 -5
- package/app/.next-cli-build/server/chunks/8499.js +1 -1
- package/app/.next-cli-build/server/chunks/8537.js +1 -1
- package/app/.next-cli-build/server/chunks/862.js +1 -1
- package/app/.next-cli-build/server/chunks/869.js +1 -1
- package/app/.next-cli-build/server/chunks/8750.js +1 -1
- package/app/.next-cli-build/server/instrumentation.js.nft.json +1 -1
- package/app/.next-cli-build/server/middleware-build-manifest.js +1 -1
- package/app/.next-cli-build/server/middleware.js +4 -4
- package/app/.next-cli-build/server/middleware.js.nft.json +1 -1
- package/app/.next-cli-build/server/pages/404.html +1 -1
- package/app/.next-cli-build/server/pages/500.html +1 -1
- package/app/.next-cli-build/server/server-reference-manifest.js +1 -1
- package/app/.next-cli-build/server/server-reference-manifest.json +1 -1
- package/app/.next-cli-build/static/chunks/{1321-b974e2c6d758f2eb.js → 1321-6b997794bb843da8.js} +1 -1
- package/app/.next-cli-build/static/chunks/{7780-8fef40eb7ea5a323.js → 7780-f3508723257e47c1.js} +1 -1
- package/app/.next-cli-build/static/chunks/app/(dashboard)/dashboard/cli-tools/{page-8f34a6d3a99ebd37.js → page-f7cbd07558c7b42e.js} +1 -1
- package/app/.next-cli-build/static/css/01b1ec05918332c3.css +1 -0
- package/app/package.json +79 -78
- package/app/src/mitm/server.js +40 -42
- package/cli.js +10 -0
- package/hooks/opencodeBridgeRuntime.js +77 -0
- package/hooks/sqliteRuntime.js +156 -156
- package/hooks/trayRuntime.js +107 -107
- package/package.json +49 -49
- package/vendor/opencode-free-bridge/LICENSE +21 -0
- package/vendor/opencode-free-bridge/README.md +762 -0
- package/vendor/opencode-free-bridge/THIRD_PARTY_NOTICES.md +33 -0
- package/vendor/opencode-free-bridge/UPSTREAM_COMMIT +1 -0
- package/vendor/opencode-free-bridge/identity.js +2624 -0
- package/vendor/opencode-free-bridge/package.json +54 -0
- package/vendor/opencode-free-bridge/panel.js +515 -0
- package/vendor/opencode-free-bridge/proxy.js +980 -0
- package/vendor/opencode-free-bridge/server.js +3357 -0
- package/vendor/opencode-free-bridge/token-budget.js +107 -0
- package/vendor/opencode-free-bridge/transport-baseline.json +39 -0
- package/app/.next-cli-build/static/css/8ba5c8dbc4e30aa0.css +0 -1
- /package/app/.next-cli-build/static/{X4uxXfKI78mMCnlqFsTAr → DryEkcB2pIbzN1YYgNcUS}/_buildManifest.js +0 -0
- /package/app/.next-cli-build/static/{X4uxXfKI78mMCnlqFsTAr → DryEkcB2pIbzN1YYgNcUS}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,3357 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
const dns = require("dns");
|
|
3
|
+
dns.setDefaultResultOrder(process.env.DNS_RESULT_ORDER || "ipv4first");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
let VERSION = "1.5.0";
|
|
7
|
+
try {
|
|
8
|
+
VERSION = require("./package.json").version || VERSION;
|
|
9
|
+
} catch {}
|
|
10
|
+
const { effectiveMaxTokens } = require("./token-budget");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load a sibling module, or a fallback when the file is not on disk yet.
|
|
14
|
+
*
|
|
15
|
+
* This exists because of a real upgrade path: 2.1.0's self-updater had no
|
|
16
|
+
* `proxy.js`/`panel.js` in its file list, so updating that install to 2.2.0
|
|
17
|
+
* wrote a `server.js` that required modules the updater never fetched. A hard
|
|
18
|
+
* `require` turned that into a crash loop (`Cannot find module './proxy'`)
|
|
19
|
+
* after the restart, with no way back except reinstalling. A missing optional
|
|
20
|
+
* module must degrade to direct egress, never prevent the process from booting.
|
|
21
|
+
*
|
|
22
|
+
* Only a missing *own* module is tolerated. If `proxy.js` exists but requires
|
|
23
|
+
* something absent, that is a broken install and the error must surface.
|
|
24
|
+
*/
|
|
25
|
+
function loadLocalModule(name, fallback) {
|
|
26
|
+
try {
|
|
27
|
+
return require(`./${name}`);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code !== "MODULE_NOT_FOUND" || !String(err.message).includes(`'./${name}'`)) throw err;
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const PROXY_FALLBACK = {
|
|
35
|
+
getProxyUrl: () => "",
|
|
36
|
+
withProxy: (init) => init,
|
|
37
|
+
netFetch: (url, init) => fetch(url, init),
|
|
38
|
+
describe: () => ({
|
|
39
|
+
mode: "direct",
|
|
40
|
+
proxy: null,
|
|
41
|
+
hasCredentials: false,
|
|
42
|
+
source: "none",
|
|
43
|
+
locked: false,
|
|
44
|
+
configPath: null,
|
|
45
|
+
error: "proxy.js belum terpasang; egress langsung. Jalankan ulang install.sh.",
|
|
46
|
+
}),
|
|
47
|
+
setProxyUrl: () => ({
|
|
48
|
+
ok: false,
|
|
49
|
+
error: "proxy.js belum terpasang. Jalankan ulang install.sh atau tunggu perbaikan otomatis.",
|
|
50
|
+
}),
|
|
51
|
+
testProxy: async () => ({ ok: false, error: "proxy.js belum terpasang." }),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const PANEL_FALLBACK = {
|
|
55
|
+
renderPanel: () =>
|
|
56
|
+
"<!doctype html><meta charset=\"utf-8\"><title>opencode-free-bridge</title>" +
|
|
57
|
+
"<body style=\"font:16px/1.5 system-ui;max-width:40rem;margin:4rem auto;padding:0 1rem\">" +
|
|
58
|
+
"<h1>Instalasi belum lengkap</h1>" +
|
|
59
|
+
"<p><code>proxy.js</code> dan <code>panel.js</code> tidak ditemukan di direktori ini. " +
|
|
60
|
+
"Bridge tetap berjalan dengan egress langsung. Jalankan ulang " +
|
|
61
|
+
"<code>install.sh</code> untuk memasang panel.</p></body>",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Egress proxy and the panel that configures it. Loaded before anything else
|
|
65
|
+
// touches the network so the first request already honours a saved proxy.
|
|
66
|
+
let proxy = loadLocalModule("proxy", PROXY_FALLBACK);
|
|
67
|
+
let panel = loadLocalModule("panel", PANEL_FALLBACK);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Local modules this server requires by relative path. The self-updater uses
|
|
71
|
+
* this to refuse an update that would install a `server.js` whose imports are
|
|
72
|
+
* not part of the release, and boot uses it to repair a half-installed tree.
|
|
73
|
+
*/
|
|
74
|
+
function localRequires(source) {
|
|
75
|
+
const names = new Set();
|
|
76
|
+
// Strip comments first: the doc comment above names `require("./x")` as an
|
|
77
|
+
// example, and scanning it produced a bogus `x.js` dependency.
|
|
78
|
+
const text = String(source || "")
|
|
79
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
80
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
|
|
81
|
+
// `require("./x")` and the tolerant `loadLocalModule("x", …)` loader both
|
|
82
|
+
// name a file that must be present for this server to work.
|
|
83
|
+
for (const re of [/\brequire\(\s*["']\.\/([A-Za-z0-9._-]+)["']\s*\)/g, /\bloadLocalModule\(\s*["']([A-Za-z0-9._-]+)["']/g]) {
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = re.exec(text))) {
|
|
86
|
+
if (match[1].endsWith(".json")) continue;
|
|
87
|
+
const name = match[1].endsWith(".js") ? match[1] : `${match[1]}.js`;
|
|
88
|
+
names.add(name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...names];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fetch files that `server.js` requires but that are not on disk.
|
|
96
|
+
*
|
|
97
|
+
* 2.1.0 shipped a self-updater whose file list predated `proxy.js`/`panel.js`,
|
|
98
|
+
* so an update to 2.2.0 wrote a server that could not boot. That install now
|
|
99
|
+
* starts with the fallbacks above; this repairs it on the next boot instead of
|
|
100
|
+
* leaving the panel permanently unavailable. Best-effort: a failure keeps the
|
|
101
|
+
* fallback, never blocks startup.
|
|
102
|
+
*/
|
|
103
|
+
async function repairMissingModules(dir = __dirname) {
|
|
104
|
+
const fs = require("fs");
|
|
105
|
+
const path = require("path");
|
|
106
|
+
const required = new Set(localRequires(fs.readFileSync(path.join(dir, "server.js"), "utf8")));
|
|
107
|
+
for (const name of ["proxy.js", "panel.js"]) required.add(name);
|
|
108
|
+
const missing = [...required].filter((f) => !fs.existsSync(path.join(dir, f)));
|
|
109
|
+
const repaired = [];
|
|
110
|
+
const failed = [];
|
|
111
|
+
for (const file of missing) {
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(`${UPDATE_BASE_URL}/${file}?cb=${Date.now()}`, {
|
|
114
|
+
headers: { "Cache-Control": "no-cache" },
|
|
115
|
+
signal: AbortSignal.timeout(8000),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
if (!text || text.length < 20) throw new Error("empty response");
|
|
120
|
+
new Function(text.replace(/^#!.*\n/, ""));
|
|
121
|
+
const tmp = path.join(dir, `.${file}.repair.${process.pid}`);
|
|
122
|
+
fs.writeFileSync(tmp, text, "utf8");
|
|
123
|
+
fs.renameSync(tmp, path.join(dir, file));
|
|
124
|
+
repaired.push(file);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
failed.push(`${file}: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (repaired.length && path.resolve(dir) === path.resolve(__dirname)) {
|
|
130
|
+
proxy = loadLocalModule("proxy", PROXY_FALLBACK);
|
|
131
|
+
panel = loadLocalModule("panel", PANEL_FALLBACK);
|
|
132
|
+
console.log(`[opencode-free-bridge] Repaired missing module(s): ${repaired.join(", ")}`);
|
|
133
|
+
} else if (repaired.length) {
|
|
134
|
+
console.log(`[opencode-free-bridge] Repaired missing module(s): ${repaired.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
if (failed.length) {
|
|
137
|
+
console.warn(
|
|
138
|
+
`[opencode-free-bridge] Could not repair ${failed.join(", ")}; ` +
|
|
139
|
+
"running with direct egress. Re-run install.sh to fix this install."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return { repaired, failed };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const PORT = parseInt(process.env.PORT || "20129", 10);
|
|
146
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
147
|
+
const UPSTREAM_TIMEOUT_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS || "120000", 10);
|
|
148
|
+
const MAX_BODY_BYTES = parseInt(process.env.MAX_BODY_BYTES || String(10 * 1024 * 1024), 10);
|
|
149
|
+
const GATE_RETRY_COOLDOWN_MS = parseInt(
|
|
150
|
+
process.env.GATE_RETRY_COOLDOWN_MS || "30000",
|
|
151
|
+
10
|
|
152
|
+
);
|
|
153
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN !== undefined ? process.env.CORS_ORIGIN : "";
|
|
154
|
+
// Optional egress relay. When set, every upstream inference request is sent to
|
|
155
|
+
// this relay URL instead of directly to opencode.ai, with the real origin and
|
|
156
|
+
// path carried in x-relay-target / x-relay-path (the same header contract
|
|
157
|
+
// 9Router's Cloudflare/Vercel/Deno/Netlify relays speak). The relay performs
|
|
158
|
+
// the final fetch, so upstream sees the relay's egress IP. Empty = direct.
|
|
159
|
+
const UPSTREAM_RELAY_URL = (process.env.UPSTREAM_RELAY_URL || "").trim();
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Origin-only label for `/health`. The configured relay URL may carry
|
|
163
|
+
* credentials or a token in the query string; those must not be handed to any
|
|
164
|
+
* client that can reach the local health endpoint.
|
|
165
|
+
*/
|
|
166
|
+
function relayLabel(url) {
|
|
167
|
+
try {
|
|
168
|
+
return new URL(url).origin;
|
|
169
|
+
} catch {
|
|
170
|
+
return "configured";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Wire endpoints. These are *defaults*; the live values come from identity.js,
|
|
175
|
+
// which resolves them from the upstream provider catalog (`opencode.api` in
|
|
176
|
+
// models.opencode.ai/api.json) at refresh time. Reading them per request means
|
|
177
|
+
// a /zen/v1 → /inference/v1 style migration applies without a restart.
|
|
178
|
+
const OPENCODE_RESPONSES_URL =
|
|
179
|
+
process.env.OPENCODE_RESPONSES_URL || "https://opencode.ai/inference/v1/responses";
|
|
180
|
+
const OPENCODE_CHAT_URL =
|
|
181
|
+
process.env.OPENCODE_CHAT_URL || "https://opencode.ai/inference/v1/chat/completions";
|
|
182
|
+
const OPENCODE_MODELS_URL =
|
|
183
|
+
process.env.OPENCODE_MODELS_URL || "https://opencode.ai/inference/v1/models";
|
|
184
|
+
const OPENCODE_RELEASES_ATOM_URL =
|
|
185
|
+
process.env.OPENCODE_RELEASES_ATOM_URL || "https://github.com/anomalyco/opencode/releases.atom";
|
|
186
|
+
const UPDATE_HINT =
|
|
187
|
+
"Update opencode-free-bridge: curl -fsSL https://raw.githubusercontent.com/azrinaldi/opencode-free-bridge/main/install.sh | bash";
|
|
188
|
+
// Self-update (like the CLI's `update: auto`): poll this repo's package.json and
|
|
189
|
+
// replace the installed files when a newer release exists. Enabled by default;
|
|
190
|
+
// AUTO_UPDATE=0 disables it. Files are syntax-checked before being swapped in,
|
|
191
|
+
// and the swap is rename-per-file, so a partial download can never leave a
|
|
192
|
+
// broken install.
|
|
193
|
+
const UPDATE_BASE_URL =
|
|
194
|
+
process.env.OPENCODE_BRIDGE_UPDATE_BASE ||
|
|
195
|
+
"https://raw.githubusercontent.com/azrinaldi/opencode-free-bridge/main";
|
|
196
|
+
const AUTO_UPDATE = process.env.AUTO_UPDATE !== "0" && process.env.AUTO_UPDATE !== "false";
|
|
197
|
+
const UPDATE_FILES = [
|
|
198
|
+
"package.json",
|
|
199
|
+
"identity.js",
|
|
200
|
+
"token-budget.js",
|
|
201
|
+
"verify.js",
|
|
202
|
+
"gate-drill.js",
|
|
203
|
+
"transport-fingerprint.js",
|
|
204
|
+
"transport-baseline.json",
|
|
205
|
+
"tunnel-doctor.sh",
|
|
206
|
+
"proxy.js",
|
|
207
|
+
"panel.js",
|
|
208
|
+
"egress-proxy.js",
|
|
209
|
+
"server.js",
|
|
210
|
+
"THIRD_PARTY_NOTICES.md",
|
|
211
|
+
];
|
|
212
|
+
let updateInFlight = null;
|
|
213
|
+
let lastUpdateCheck = 0;
|
|
214
|
+
const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compare two `x.y.z` versions. Returns true when `remote` is newer.
|
|
218
|
+
*/
|
|
219
|
+
function isNewerVersion(current, remote) {
|
|
220
|
+
const a = String(current).split(".").map(Number);
|
|
221
|
+
const b = String(remote).split(".").map(Number);
|
|
222
|
+
for (let i = 0; i < 3; i++) {
|
|
223
|
+
const av = Number.isFinite(a[i]) ? a[i] : 0;
|
|
224
|
+
const bv = Number.isFinite(b[i]) ? b[i] : 0;
|
|
225
|
+
if (bv !== av) return bv > av;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The CLI's `update: auto`, in miniature: check this repo's published
|
|
232
|
+
* package.json, and when it is newer, download every shipped file into a temp
|
|
233
|
+
* file, syntax-check the JS, then rename them into place and exit so the
|
|
234
|
+
* service manager restarts us. Any failure leaves the running install intact.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately *not* on the request path: it runs alongside model discovery,
|
|
237
|
+
* at most once per UPDATE_CHECK_TTL_MS.
|
|
238
|
+
*/
|
|
239
|
+
async function checkAndApplyUpdate() {
|
|
240
|
+
if (!AUTO_UPDATE) return { updated: false, reason: "disabled" };
|
|
241
|
+
if (updateInFlight) return updateInFlight;
|
|
242
|
+
if (Date.now() - lastUpdateCheck < UPDATE_CHECK_TTL_MS) {
|
|
243
|
+
return { updated: false, reason: "fresh" };
|
|
244
|
+
}
|
|
245
|
+
const fs = require("fs");
|
|
246
|
+
const path = require("path");
|
|
247
|
+
if (
|
|
248
|
+
process.env.AUTO_UPDATE !== "1" &&
|
|
249
|
+
process.env.AUTO_UPDATE !== "force" &&
|
|
250
|
+
!process.env.OPENCODE_BRIDGE_UPDATE_BASE &&
|
|
251
|
+
fs.existsSync(path.join(__dirname, ".git"))
|
|
252
|
+
) {
|
|
253
|
+
return { updated: false, reason: "git-checkout" };
|
|
254
|
+
}
|
|
255
|
+
updateInFlight = (async () => {
|
|
256
|
+
lastUpdateCheck = Date.now();
|
|
257
|
+
const tmpFiles = [];
|
|
258
|
+
const cb = Date.now();
|
|
259
|
+
try {
|
|
260
|
+
const pkgRes = await proxy.netFetch(`${UPDATE_BASE_URL}/package.json?cb=${cb}`, {
|
|
261
|
+
headers: { "Cache-Control": "no-cache" },
|
|
262
|
+
signal: AbortSignal.timeout(8000),
|
|
263
|
+
});
|
|
264
|
+
if (!pkgRes.ok) throw new Error(`HTTP ${pkgRes.status} fetching package.json`);
|
|
265
|
+
const remotePkg = await pkgRes.json();
|
|
266
|
+
const remoteVersion = String(remotePkg?.version || "").trim();
|
|
267
|
+
if (!/^\d+\.\d+\.\d+$/.test(remoteVersion)) throw new Error("invalid remote version");
|
|
268
|
+
if (!isNewerVersion(VERSION, remoteVersion)) {
|
|
269
|
+
return { updated: false, reason: "up-to-date" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const file of UPDATE_FILES) {
|
|
273
|
+
const res = await proxy.netFetch(`${UPDATE_BASE_URL}/${file}?cb=${cb}`, {
|
|
274
|
+
headers: { "Cache-Control": "no-cache" },
|
|
275
|
+
signal: AbortSignal.timeout(15000),
|
|
276
|
+
});
|
|
277
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${file}`);
|
|
278
|
+
const text = await res.text();
|
|
279
|
+
if (!text || text.length < 20) throw new Error(`empty response for ${file}`);
|
|
280
|
+
if (file.endsWith(".js")) {
|
|
281
|
+
try {
|
|
282
|
+
new Function(text.replace(/^#!.*\n/, ""));
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw new Error(`syntax check failed for ${file}: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
} else if (file.endsWith(".json")) {
|
|
287
|
+
try {
|
|
288
|
+
JSON.parse(text);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
throw new Error(`invalid JSON in ${file}: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
} else if (file.endsWith(".sh")) {
|
|
293
|
+
// `new Function` cannot parse bash. Check the shebang here and let
|
|
294
|
+
// `bash -n` parse it below, once it is on disk.
|
|
295
|
+
if (!/^#!.*\b(ba)?sh\b/.test(text)) throw new Error(`${file} is not a shell script`);
|
|
296
|
+
}
|
|
297
|
+
const tmpPath = path.join(__dirname, `.${file}.tmp.${process.pid}`);
|
|
298
|
+
fs.writeFileSync(tmpPath, text, "utf8");
|
|
299
|
+
if (file.endsWith(".sh")) {
|
|
300
|
+
let parsed = true;
|
|
301
|
+
try {
|
|
302
|
+
require("child_process").execFileSync("bash", ["-n", tmpPath], {
|
|
303
|
+
stdio: "ignore",
|
|
304
|
+
timeout: 5000,
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// No bash on this host is not a syntax failure; the shebang check
|
|
308
|
+
// above is all we can do there. Anything else is a real rejection.
|
|
309
|
+
if (!err || err.code !== "ENOENT") {
|
|
310
|
+
try {
|
|
311
|
+
fs.unlinkSync(tmpPath);
|
|
312
|
+
} catch {}
|
|
313
|
+
parsed = false;
|
|
314
|
+
throw new Error(`syntax check failed for ${file}: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (parsed) fs.chmodSync(tmpPath, 0o755);
|
|
318
|
+
}
|
|
319
|
+
tmpFiles.push({ tmpPath, targetPath: path.join(__dirname, file) });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A release can add a module that an older `UPDATE_FILES` list does not
|
|
323
|
+
// name. 2.1.0 -> 2.2.0 did exactly that: the new server.js required
|
|
324
|
+
// ./proxy and ./panel, the old updater never fetched them, and the
|
|
325
|
+
// restart crash-looped. Read the *incoming* server.js and stage every
|
|
326
|
+
// local module it imports, so the file list in the running process can
|
|
327
|
+
// never be the reason a valid release fails to boot.
|
|
328
|
+
const incoming = tmpFiles.find((f) => path.basename(f.targetPath) === "server.js");
|
|
329
|
+
if (incoming) {
|
|
330
|
+
const declared = new Set(tmpFiles.map((f) => path.basename(f.targetPath)));
|
|
331
|
+
for (const file of localRequires(fs.readFileSync(incoming.tmpPath, "utf8"))) {
|
|
332
|
+
if (declared.has(file) || fs.existsSync(path.join(__dirname, file))) continue;
|
|
333
|
+
const res = await proxy.netFetch(`${UPDATE_BASE_URL}/${file}?cb=${cb}`, {
|
|
334
|
+
headers: { "Cache-Control": "no-cache" },
|
|
335
|
+
signal: AbortSignal.timeout(15000),
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching required module ${file}`);
|
|
338
|
+
const text = await res.text();
|
|
339
|
+
if (!text || text.length < 20) throw new Error(`empty response for required module ${file}`);
|
|
340
|
+
try {
|
|
341
|
+
new Function(text.replace(/^#!.*\n/, ""));
|
|
342
|
+
} catch (err) {
|
|
343
|
+
throw new Error(`syntax check failed for required module ${file}: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
const tmpPath = path.join(__dirname, `.${file}.tmp.${process.pid}`);
|
|
346
|
+
fs.writeFileSync(tmpPath, text, "utf8");
|
|
347
|
+
tmpFiles.push({ tmpPath, targetPath: path.join(__dirname, file) });
|
|
348
|
+
declared.add(file);
|
|
349
|
+
console.log(`[opencode-free-bridge] Staged newly required module ${file}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const { tmpPath, targetPath } of tmpFiles) fs.renameSync(tmpPath, targetPath);
|
|
354
|
+
console.log(
|
|
355
|
+
`[opencode-free-bridge] Self-updated ${VERSION} → ${remoteVersion}; exiting for service restart.`
|
|
356
|
+
);
|
|
357
|
+
server.close(() => process.exit(0));
|
|
358
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
359
|
+
return { updated: true, version: remoteVersion };
|
|
360
|
+
} catch (err) {
|
|
361
|
+
for (const { tmpPath } of tmpFiles) {
|
|
362
|
+
try {
|
|
363
|
+
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
console.warn(`[opencode-free-bridge] Self-update skipped: ${err.message}`);
|
|
367
|
+
return { updated: false, error: err.message };
|
|
368
|
+
} finally {
|
|
369
|
+
updateInFlight = null;
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
return updateInFlight;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isFreeTierCliGateError(status, errType, errMsg) {
|
|
376
|
+
if (errType === "FreeTierError" || errType === "UpgradeRequired") return true;
|
|
377
|
+
// 426 is the version-floor handshake: upstream names the minimum client
|
|
378
|
+
// version it will accept. Treat it as a gate signal so the identity layer can
|
|
379
|
+
// adopt that floor and retry.
|
|
380
|
+
if (status === 426) return true;
|
|
381
|
+
if (status !== 403 && status !== 401) return false;
|
|
382
|
+
const m = String(errMsg || "").toLowerCase();
|
|
383
|
+
return (
|
|
384
|
+
m.includes("freetiererror") ||
|
|
385
|
+
m.includes("free tier") ||
|
|
386
|
+
m.includes("free models") ||
|
|
387
|
+
m.includes("opencode cli") ||
|
|
388
|
+
m.includes("only available in opencode") ||
|
|
389
|
+
m.includes("upgrade required") ||
|
|
390
|
+
m.includes("or newer is required")
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const identity = require("./identity");
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Wire endpoints for this request. Environment variables pin them; otherwise
|
|
397
|
+
* the values derived from the upstream provider catalog are used, falling back
|
|
398
|
+
* to the shipped defaults until the first successful refresh. Resolved per
|
|
399
|
+
* call, so a catalog change applies on the next request.
|
|
400
|
+
*/
|
|
401
|
+
function resolveEndpoints(modelId = null) {
|
|
402
|
+
const derived = identity.getEndpoints(modelId);
|
|
403
|
+
return {
|
|
404
|
+
responsesUrl: process.env.OPENCODE_RESPONSES_URL || derived.responsesUrl || OPENCODE_RESPONSES_URL,
|
|
405
|
+
chatUrl: process.env.OPENCODE_CHAT_URL || derived.chatUrl || OPENCODE_CHAT_URL,
|
|
406
|
+
modelsUrl: process.env.OPENCODE_MODELS_URL || derived.modelsUrl || OPENCODE_MODELS_URL,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The identity (user-agent, session-id format, header set) is owned by
|
|
411
|
+
// identity.js, which derives it from opencode's public source and refreshes it
|
|
412
|
+
// lazily — on request, never on a timer. `currentUserAgent` mirrors it for the
|
|
413
|
+
// few call sites that need a plain string.
|
|
414
|
+
let currentUserAgent = process.env.OPENCODE_USER_AGENT || identity.getUserAgent();
|
|
415
|
+
|
|
416
|
+
function syncUserAgent() {
|
|
417
|
+
if (!process.env.OPENCODE_USER_AGENT) currentUserAgent = identity.getUserAgent();
|
|
418
|
+
return currentUserAgent;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Send an inference request to `url`, optionally through the configured egress
|
|
423
|
+
* relay. The relay contract is the one 9Router's deployable relays speak:
|
|
424
|
+
* POST <relay> with `x-relay-target: <origin>` and `x-relay-path: <path>`, and
|
|
425
|
+
* the relay replays method/headers/body at that origin. The origin and path
|
|
426
|
+
* never travel in the URL, so a relay deployed at a bare workers.dev hostname
|
|
427
|
+
* works unchanged.
|
|
428
|
+
*
|
|
429
|
+
* The request body is serialized once by the caller and passed as a string, so
|
|
430
|
+
* every retry path can replay it.
|
|
431
|
+
*
|
|
432
|
+
* A configured egress proxy applies to whichever URL is finally fetched, so a
|
|
433
|
+
* relay deployment can itself be dialled through the proxy.
|
|
434
|
+
*
|
|
435
|
+
* `egressUrl` pins the egress for this call. The chat handler picks one egress
|
|
436
|
+
* per request and reuses it across retries, so a retry that must stay on the IP
|
|
437
|
+
* that answered does not silently move to a different one mid-conversation.
|
|
438
|
+
* Without it, a pool picks per call.
|
|
439
|
+
*/
|
|
440
|
+
function upstreamFetch(url, init, egressUrl = null) {
|
|
441
|
+
if (!UPSTREAM_RELAY_URL) return proxy.netFetch(url, init, egressUrl);
|
|
442
|
+
const parsed = new URL(url);
|
|
443
|
+
const headers = { ...(init.headers || {}) };
|
|
444
|
+
headers["x-relay-target"] = parsed.origin;
|
|
445
|
+
headers["x-relay-path"] = parsed.pathname + parsed.search;
|
|
446
|
+
return proxy.netFetch(UPSTREAM_RELAY_URL, { ...init, headers }, egressUrl);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Send a request through the chosen egress, and move to the next pool entry when
|
|
451
|
+
* the egress itself is unreachable.
|
|
452
|
+
*
|
|
453
|
+
* A dead proxy is not an upstream refusal: nothing reached upstream, so retrying
|
|
454
|
+
* on another entry costs one connection, while returning a 500 to the client
|
|
455
|
+
* wastes the request entirely. This is the failure mode a pool exists to absorb.
|
|
456
|
+
*
|
|
457
|
+
* With one proxy (or none) there is nothing to fail over to, so the transport
|
|
458
|
+
* error is rethrown for the caller's existing handling.
|
|
459
|
+
*/
|
|
460
|
+
async function egressFetch(url, init, state) {
|
|
461
|
+
try {
|
|
462
|
+
const res = await upstreamFetch(url, init, state.egressUrl);
|
|
463
|
+
state.transportFailures = 0;
|
|
464
|
+
return res;
|
|
465
|
+
} catch (err) {
|
|
466
|
+
const pool = proxy.describe().pool;
|
|
467
|
+
const canFailover = pool && pool.active && pool.enabled > 1;
|
|
468
|
+
if (!canFailover || !state.egressUrl) throw err;
|
|
469
|
+
|
|
470
|
+
proxy.markFailure(state.egressUrl, { reason: `transport: ${err.message}`.slice(0, 200) });
|
|
471
|
+
const next = proxy.pick({ exclude: state.egressUrl });
|
|
472
|
+
if (!next.url || next.url === state.egressUrl) throw err;
|
|
473
|
+
|
|
474
|
+
console.warn(
|
|
475
|
+
`[opencode-free-bridge] Egress ${
|
|
476
|
+
proxy.displayHost(state.egressUrl) || "direct"
|
|
477
|
+
} unreachable (${err.message}); retrying through ${proxy.displayHost(next.url) || "direct"}`
|
|
478
|
+
);
|
|
479
|
+
state.egress = next;
|
|
480
|
+
state.egressUrl = next.url;
|
|
481
|
+
state.transportFailures = (state.transportFailures || 0) + 1;
|
|
482
|
+
// One hop only. A pool where every entry is down must surface the error
|
|
483
|
+
// instead of walking the whole list inside a single client request.
|
|
484
|
+
return upstreamFetch(url, init, state.egressUrl);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Record a refusal against the egress that produced it.
|
|
490
|
+
*
|
|
491
|
+
* An empty URL is the `direct` pool entry, which is a real egress, so this
|
|
492
|
+
* cannot test truthiness: only a missing state means "nothing to record".
|
|
493
|
+
*/
|
|
494
|
+
function recordEgressFailure(state, { reason, cooldownMs = null } = {}) {
|
|
495
|
+
const url = state && state.egressUrl;
|
|
496
|
+
if (url === null || url === undefined) return;
|
|
497
|
+
const pool = proxy.describe().pool;
|
|
498
|
+
if (!pool || !pool.active) return;
|
|
499
|
+
proxy.markFailure(url, { reason, cooldownMs });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isValidV2UserAgent(ua) {
|
|
503
|
+
const match = String(ua || "")
|
|
504
|
+
.trim()
|
|
505
|
+
.match(/^opencode\/[a-z0-9-]+\/(\d+)\.\d+\.\d+\/[a-z0-9-]+$/i);
|
|
506
|
+
return Boolean(match) && parseInt(match[1], 10) >= 2;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* A User-Agent the free-tier gate accepts, or the identity's own.
|
|
511
|
+
*
|
|
512
|
+
* Measured (clean egress, `read` tool present): a request without a
|
|
513
|
+
* `User-Agent`, with a bare `opencode`, or with `Mozilla/5.0 …` / `curl/8.18.0`
|
|
514
|
+
* is refused with the same 403 FreeTierError that a blocked IP gets. Only the
|
|
515
|
+
* `opencode/<channel>/<semver>/<name>` shape passes, and the version is not
|
|
516
|
+
* checked for freshness (`opencode/9.9.9` was accepted).
|
|
517
|
+
*
|
|
518
|
+
* `OPENCODE_USER_AGENT` is an operator override, so it is honoured — but an
|
|
519
|
+
* override that the gate would reject is reported instead of silently
|
|
520
|
+
* producing a 403 that looks like an allowance problem.
|
|
521
|
+
*/
|
|
522
|
+
function gateSafeUserAgent() {
|
|
523
|
+
const override = process.env.OPENCODE_USER_AGENT;
|
|
524
|
+
if (override && /^opencode\/[A-Za-z0-9-]+\/\d+\.\d+\.\d+\/[A-Za-z0-9-]+$/i.test(String(override).trim())) {
|
|
525
|
+
return String(override).trim();
|
|
526
|
+
}
|
|
527
|
+
if (override) {
|
|
528
|
+
console.warn(
|
|
529
|
+
"[opencode-free-bridge] OPENCODE_USER_AGENT does not match the shape the free-tier gate accepts " +
|
|
530
|
+
"(opencode/<channel>/<semver>/<name>); using the derived identity instead"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const derived = syncUserAgent();
|
|
534
|
+
if (/^opencode\//i.test(derived)) return derived;
|
|
535
|
+
// Last resort: the derived identity itself is malformed. Keep the request
|
|
536
|
+
// shaped as an opencode client rather than sending something the gate refuses.
|
|
537
|
+
return "opencode/latest/0.0.0/cli";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// --- ID Generator (matches OpenCode v2 CLI format) ---
|
|
541
|
+
// The shape (charset, total length, multiplier, prefix) comes from identity.js,
|
|
542
|
+
// which derives it from opencode's own `packages/schema/src/identifier.ts`.
|
|
543
|
+
// Kept as a thin wrapper here so existing call sites and tests keep working.
|
|
544
|
+
function createId(descending, timestamp = Date.now()) {
|
|
545
|
+
return identity.createId(descending, timestamp);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Live model list, discovered from upstream at boot and refreshed hourly.
|
|
549
|
+
// No hardcoded fallback: an empty list honestly means "upstream not reached
|
|
550
|
+
// yet", so gateways/harnesses apply their own fallback instead of being
|
|
551
|
+
// served a stale lie.
|
|
552
|
+
let activeModels = [];
|
|
553
|
+
let cachedModelsPayload = '{"object":"list","data":[]}';
|
|
554
|
+
let lastDiscoveryTime = 0;
|
|
555
|
+
const DISCOVERY_TTL_MS = 60 * 60 * 1000; // Auto-refresh every 1 hour
|
|
556
|
+
// Single-flight: concurrent /v1/models hits after TTL expiry share one upstream
|
|
557
|
+
// fetch instead of stampeding (50 parallel GETs = 1 upstream call, not 50).
|
|
558
|
+
let discoveryInFlight = null;
|
|
559
|
+
// CLI version feed is slow (5s timeout) and changes rarely: cache 6h, refresh
|
|
560
|
+
// in parallel so model discovery never waits on it.
|
|
561
|
+
let lastCliVersionCheckTime = 0;
|
|
562
|
+
const CLI_VERSION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
563
|
+
|
|
564
|
+
function rebuildDiscoveryPayloads() {
|
|
565
|
+
cachedModelsPayload = JSON.stringify({ object: "list", data: activeModels });
|
|
566
|
+
}
|
|
567
|
+
rebuildDiscoveryPayloads();
|
|
568
|
+
|
|
569
|
+
function isLocalOrigin(origin) {
|
|
570
|
+
if (typeof origin !== "string" || !origin) return false;
|
|
571
|
+
return /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim());
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const LOOPBACK_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)$/i;
|
|
575
|
+
|
|
576
|
+
/** Hostname from a `Host` header, without the port. IPv6 literals keep brackets off. */
|
|
577
|
+
function hostnameOf(hostHeader) {
|
|
578
|
+
const raw = String(hostHeader || "").trim();
|
|
579
|
+
if (!raw) return "";
|
|
580
|
+
const v6 = raw.match(/^\[([^\]]+)\](?::\d+)?$/);
|
|
581
|
+
if (v6) return v6[1];
|
|
582
|
+
return raw.replace(/:\d+$/, "");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Admin-surface gate: the panel and `/api/proxy` can change where every
|
|
587
|
+
* outbound request goes, so they must not be reachable from a foreign page or
|
|
588
|
+
* a rebound DNS name.
|
|
589
|
+
*
|
|
590
|
+
* The bridge is a local tool with no authentication. Two browser attacks
|
|
591
|
+
* matter here:
|
|
592
|
+
*
|
|
593
|
+
* - CSRF: any page the operator visits could POST to 127.0.0.1 and point the
|
|
594
|
+
* egress at an attacker's proxy. A form POST cannot set
|
|
595
|
+
* `Content-Type: application/json`, so requiring it removes the simple
|
|
596
|
+
* request; the Origin/Sec-Fetch-Site checks below cover the rest.
|
|
597
|
+
* - DNS rebinding: an attacker domain resolving to 127.0.0.1 makes the
|
|
598
|
+
* browser send `Host: evil.example`, which a loopback-only listener would
|
|
599
|
+
* otherwise accept.
|
|
600
|
+
*
|
|
601
|
+
* `PANEL_ALLOWED_HOSTS` (comma-separated) adds names for a reverse proxy or a
|
|
602
|
+
* deliberate LAN deployment. A bind address other than a wildcard is allowed
|
|
603
|
+
* automatically, so `HOST=192.168.1.5` keeps its own panel working.
|
|
604
|
+
*/
|
|
605
|
+
function allowedAdminHosts() {
|
|
606
|
+
const hosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
607
|
+
for (const extra of String(process.env.PANEL_ALLOWED_HOSTS || "").split(",")) {
|
|
608
|
+
const trimmed = extra.trim().toLowerCase();
|
|
609
|
+
if (trimmed) hosts.add(trimmed);
|
|
610
|
+
}
|
|
611
|
+
const bound = String(HOST || "").toLowerCase();
|
|
612
|
+
const wildcard = !bound || bound === "0.0.0.0" || bound === "::" || bound === "[::]";
|
|
613
|
+
if (!wildcard) hosts.add(bound);
|
|
614
|
+
if (wildcard) {
|
|
615
|
+
// A wildcard bind is usually reached by one of this machine's own
|
|
616
|
+
// addresses (LAN IP, VPS public IP). Those are safe to accept and make
|
|
617
|
+
// `HOST=0.0.0.0` usable without configuration; a rebound attacker domain
|
|
618
|
+
// is still not in this set. Domain names go in PANEL_ALLOWED_HOSTS.
|
|
619
|
+
const os = require("os");
|
|
620
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
621
|
+
for (const addr of list || []) {
|
|
622
|
+
if (addr && addr.address) hosts.add(String(addr.address).toLowerCase());
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return hosts;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function adminRequestError(req) {
|
|
630
|
+
const host = hostnameOf(req.headers?.host).toLowerCase();
|
|
631
|
+
if (!host) return "Header Host wajib ada.";
|
|
632
|
+
if (!allowedAdminHosts().has(host)) {
|
|
633
|
+
return `Host ${host} tidak diizinkan. Panel hanya menerima host lokal; tambahkan ke PANEL_ALLOWED_HOSTS bila ini memang disengaja.`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const site = String(req.headers?.["sec-fetch-site"] || "").toLowerCase();
|
|
637
|
+
if (site === "cross-site") {
|
|
638
|
+
return "Permintaan lintas-situs ditolak. Buka panel langsung dari host ini.";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// An Origin must match the Host this request was sent to (which covers the
|
|
642
|
+
// panel itself and an SSH/tunnel forward that keeps the browser's origin),
|
|
643
|
+
// or be explicitly allow-listed in CORS_ORIGIN. A loopback origin on a
|
|
644
|
+
// *different* port is another local service, not this panel, so it is not
|
|
645
|
+
// trusted automatically.
|
|
646
|
+
const origin = String(req.headers?.origin || "").trim();
|
|
647
|
+
if (origin) {
|
|
648
|
+
let originHost = "";
|
|
649
|
+
try {
|
|
650
|
+
originHost = new URL(origin).hostname.toLowerCase();
|
|
651
|
+
} catch {
|
|
652
|
+
return `Origin ${origin} tidak valid.`;
|
|
653
|
+
}
|
|
654
|
+
const configured = String(CORS_ORIGIN || "")
|
|
655
|
+
.split(",")
|
|
656
|
+
.map((s) => s.trim())
|
|
657
|
+
.filter(Boolean);
|
|
658
|
+
if (originHost !== host && !configured.includes(origin)) {
|
|
659
|
+
return `Origin ${origin} tidak diizinkan.`;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Kept for callers and tests that expect the old name. Consults the handshake
|
|
668
|
+
* and derives the rest of the recipe from opencode's source.
|
|
669
|
+
*
|
|
670
|
+
* This is a *poll*: it bypasses the identity freshness guard so the release
|
|
671
|
+
* check happens on its own schedule, but adoption still follows UPDATE_MODE.
|
|
672
|
+
* In `reactive` mode a release that has not moved the gate is recorded (and
|
|
673
|
+
* reported by /health as `identity.available`) without being adopted — forcing
|
|
674
|
+
* it here would silently turn `reactive` into `always` every 6h.
|
|
675
|
+
*
|
|
676
|
+
* The `lastCliVersionCheckTime` guard keeps the handshake read at most once per
|
|
677
|
+
* CLI_VERSION_TTL_MS even when called repeatedly.
|
|
678
|
+
*/
|
|
679
|
+
async function refreshCliVersion() {
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
if (now - lastCliVersionCheckTime < CLI_VERSION_TTL_MS) {
|
|
682
|
+
return { updated: false, reason: "fresh" };
|
|
683
|
+
}
|
|
684
|
+
const result = await identity.refresh({ recheck: true });
|
|
685
|
+
syncUserAgent();
|
|
686
|
+
lastCliVersionCheckTime = now;
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Auto-discovery from OpenCode upstream gateway.
|
|
691
|
+
// Single-flight: concurrent callers share one in-flight fetch.
|
|
692
|
+
// The CLI-version feed refreshes in parallel (fire-and-forget) so a slow
|
|
693
|
+
// 5s feed never delays the model list.
|
|
694
|
+
async function discoverModels() {
|
|
695
|
+
if (discoveryInFlight) return discoveryInFlight;
|
|
696
|
+
discoveryInFlight = (async () => {
|
|
697
|
+
refreshCliVersion().catch(() => {});
|
|
698
|
+
checkAndApplyUpdate().catch(() => {});
|
|
699
|
+
try {
|
|
700
|
+
const res = await proxy.netFetch(resolveEndpoints().modelsUrl, {
|
|
701
|
+
headers: {
|
|
702
|
+
"Authorization": "Bearer public",
|
|
703
|
+
"User-Agent": currentUserAgent,
|
|
704
|
+
},
|
|
705
|
+
signal: AbortSignal.timeout(15000),
|
|
706
|
+
});
|
|
707
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
708
|
+
const json = await res.json();
|
|
709
|
+
const items = json.data || [];
|
|
710
|
+
|
|
711
|
+
// The provider catalog decides what is free — it is the file the CLI reads
|
|
712
|
+
// at boot, and its `cost`/`status` fields are facts about the plan. The
|
|
713
|
+
// gateway's /models list only contributes models the catalog does not
|
|
714
|
+
// describe (a few evaluator entries), because a name is not a price: a
|
|
715
|
+
// model named `-free` that the catalog marks deprecated must drop out, and
|
|
716
|
+
// one the catalog marks free must survive /models omitting it.
|
|
717
|
+
const ownedBy = new Map(items.map((i) => [String(i.id || "").toLowerCase(), i]));
|
|
718
|
+
const kept = [];
|
|
719
|
+
const seen = new Set();
|
|
720
|
+
const keep = (id) => {
|
|
721
|
+
const lower = String(id).toLowerCase();
|
|
722
|
+
if (seen.has(lower)) return;
|
|
723
|
+
seen.add(lower);
|
|
724
|
+
kept.push(id);
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
for (const id of identity.getFreeModels()) keep(id);
|
|
728
|
+
// With the catalog loaded this loop adds nothing today: every id /models
|
|
729
|
+
// offers is either described by the catalog (already ruled on) or is an
|
|
730
|
+
// evaluator entry. It exists for the window before the first catalog read,
|
|
731
|
+
// when the name is the only signal available — a bridge that answers
|
|
732
|
+
// /v1/models with an empty list for the first seconds after boot is worse
|
|
733
|
+
// than one that guesses and corrects itself on the next refresh.
|
|
734
|
+
for (const item of items) {
|
|
735
|
+
const id = String(item.id || "");
|
|
736
|
+
const lower = id.toLowerCase();
|
|
737
|
+
if (lower.startsWith("jev-")) continue; // systemone evaluator models
|
|
738
|
+
const verdict = identity.getCatalogVerdict(lower);
|
|
739
|
+
if (verdict) continue; // the catalog already ruled on this id
|
|
740
|
+
if (!lower.endsWith("-free") && !lower.includes("-contributor") && lower !== "big-pickle") continue;
|
|
741
|
+
keep(id);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const freeItems = kept.map((id) => ({
|
|
745
|
+
id,
|
|
746
|
+
object: "model",
|
|
747
|
+
owned_by: ownedBy.get(String(id).toLowerCase())?.owned_by || "opencode",
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
if (freeItems.length > 0) {
|
|
751
|
+
activeModels = freeItems;
|
|
752
|
+
rebuildDiscoveryPayloads();
|
|
753
|
+
lastDiscoveryTime = Date.now();
|
|
754
|
+
console.log(`[opencode-free-bridge] Auto-discovered ${activeModels.length} free models from upstream gateway.`);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.warn(`[opencode-free-bridge] Auto-discovery warning: ${err.message}. Model list stays empty until upstream answers.`);
|
|
759
|
+
}
|
|
760
|
+
})();
|
|
761
|
+
try {
|
|
762
|
+
return await discoveryInFlight;
|
|
763
|
+
} finally {
|
|
764
|
+
discoveryInFlight = null;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function normalizeModel(rawModel) {
|
|
769
|
+
if (typeof rawModel !== "string") return "";
|
|
770
|
+
const stripped = rawModel.trim().replace(/^(opencode\/|oc\/|zen\/|9router\/|openai\/)/i, "").trim();
|
|
771
|
+
if (!stripped) return "";
|
|
772
|
+
// A name that is already a real model id is passed through untouched; only a
|
|
773
|
+
// shorthand is resolved. The catalog's families are consulted first, so a
|
|
774
|
+
// revision that retired keeps resolving to a live sibling without a bridge
|
|
775
|
+
// release; the static table below remains for names the catalog does not
|
|
776
|
+
// group (and for the case where the catalog has not been read yet).
|
|
777
|
+
const lower = stripped.toLowerCase();
|
|
778
|
+
const verdict = identity.getCatalogVerdict(lower);
|
|
779
|
+
// This bridge is free-tier only: it sends `Authorization: Bearer public` and
|
|
780
|
+
// has no way to authenticate a paid request. A paid id would therefore only
|
|
781
|
+
// earn a 401/402 upstream, so it is not passed through — an alias that points
|
|
782
|
+
// at one is allowed to fall through to a free sibling instead.
|
|
783
|
+
if (verdict === "free") return stripped;
|
|
784
|
+
const derived = identity.resolveModelAlias(lower);
|
|
785
|
+
if (derived) return derived;
|
|
786
|
+
// No static table behind this. A shorthand either derives from the catalog's
|
|
787
|
+
// own `family` field or it is passed through as a literal model id. Anything
|
|
788
|
+
// else would be a name this bridge invented, and an invented name can outlive
|
|
789
|
+
// the model it points at.
|
|
790
|
+
return stripped;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Protocol routing: which models want /responses instead of /chat/completions.
|
|
794
|
+
//
|
|
795
|
+
// The shipped default is empty on purpose. It used to seed two `muse-spark`
|
|
796
|
+
// ids, both of which the catalog now marks deprecated, plus a `includes("muse")`
|
|
797
|
+
// fallback — a name rule for a model family that no longer exists. Every live
|
|
798
|
+
// free model speaks the chat lane (catalog: `@ai-sdk/openai-compatible`), and a
|
|
799
|
+
// model that needs the other lane is learned from upstream's own
|
|
800
|
+
// `ModelProtocolUnsupported` reply, which is a fact rather than a guess.
|
|
801
|
+
// `RESPONSES_MODELS` still seeds the set when upstream ships a Responses-only
|
|
802
|
+
// model before the bridge has met it.
|
|
803
|
+
const responsesModelSet = new Set(
|
|
804
|
+
(process.env.RESPONSES_MODELS || "")
|
|
805
|
+
.split(",")
|
|
806
|
+
.map((s) => s.trim().toLowerCase())
|
|
807
|
+
.filter(Boolean)
|
|
808
|
+
);
|
|
809
|
+
const chatModelSet = new Set();
|
|
810
|
+
|
|
811
|
+
function isResponsesModel(modelId) {
|
|
812
|
+
const lower = String(modelId || "").trim().toLowerCase();
|
|
813
|
+
if (!lower) return false;
|
|
814
|
+
if (chatModelSet.has(lower)) return false;
|
|
815
|
+
if (responsesModelSet.has(lower)) return true;
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Test hook: forget learned lane assignments (they persist by design). */
|
|
820
|
+
function __resetProtocolLanes() {
|
|
821
|
+
chatModelSet.clear();
|
|
822
|
+
responsesModelSet.clear();
|
|
823
|
+
for (const m of (process.env.RESPONSES_MODELS || "").split(",")) {
|
|
824
|
+
const id = m.trim().toLowerCase();
|
|
825
|
+
if (id) responsesModelSet.add(id);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function markProtocolSupport(modelId, useResponses) {
|
|
830
|
+
const lower = String(modelId || "").trim().toLowerCase();
|
|
831
|
+
if (!lower) return;
|
|
832
|
+
if (useResponses) {
|
|
833
|
+
responsesModelSet.add(lower);
|
|
834
|
+
chatModelSet.delete(lower);
|
|
835
|
+
} else {
|
|
836
|
+
chatModelSet.add(lower);
|
|
837
|
+
responsesModelSet.delete(lower);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// --- Tool fingerprint (dynamic) -------------------------------------------
|
|
842
|
+
//
|
|
843
|
+
// Upstream /inference/v1 validates the `tools` array in the request body, and
|
|
844
|
+
// this is the layer that has moved most often: first the array had to exist,
|
|
845
|
+
// then it had to contain specific tool names, then (per 9router#4837) the exact
|
|
846
|
+
// schemas. Hardcoding those definitions here meant a code change every time.
|
|
847
|
+
//
|
|
848
|
+
// The definitions now come from identity.js, which derives them at the release
|
|
849
|
+
// commit SHA named by the update handshake:
|
|
850
|
+
// 1. source — `read.ts` / `shell.ts` at that immutable commit
|
|
851
|
+
// 2. DEFAULT_TOOLS — shipped fallback, used until a source read succeeds
|
|
852
|
+
//
|
|
853
|
+
// The names below are kept for the call sites and tests that predate the
|
|
854
|
+
// change; they are read from the live profile, not frozen at load time.
|
|
855
|
+
function getFingerprintNames(scope = null, modelId = null) {
|
|
856
|
+
// Only the names actually sent upstream. A capture carries ~10 tools but
|
|
857
|
+
// `TOOL_PROFILE_SCOPE=core` sends a subset; treating the unsent ones as
|
|
858
|
+
// fingerprints would alias an unrelated client tool (`glob` → `glob_client`)
|
|
859
|
+
// for no reason. The model matters too: the release swaps `edit`+`write` for
|
|
860
|
+
// `patch` on GPT models, so the fingerprint set follows the same rule.
|
|
861
|
+
return identity.getToolList(false, scope, modelId).map((t) => t.function.name);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function getFingerprintDefinitions() {
|
|
865
|
+
return identity.getTools();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function getCanonicalTools(flat, scope = null, modelId = null) {
|
|
869
|
+
return identity.getToolList(flat, scope, modelId);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Stable JSON for schema comparison. Key order must not affect equality: two
|
|
874
|
+
* definitions that differ only in property order are the same schema.
|
|
875
|
+
*/
|
|
876
|
+
function stableStringify(val) {
|
|
877
|
+
if (!val || typeof val !== "object") return JSON.stringify(val);
|
|
878
|
+
if (Array.isArray(val)) return "[" + val.map(stableStringify).join(",") + "]";
|
|
879
|
+
const keys = Object.keys(val).sort();
|
|
880
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(val[k])).join(",") + "}";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const EMPTY_MAP = Object.freeze(new Map());
|
|
884
|
+
const paramSigCache = new WeakMap();
|
|
885
|
+
function getParamSig(params) {
|
|
886
|
+
if (!params || typeof params !== "object") return stableStringify(params);
|
|
887
|
+
let sig = paramSigCache.get(params);
|
|
888
|
+
if (sig === undefined) {
|
|
889
|
+
sig = stableStringify(params);
|
|
890
|
+
paramSigCache.set(params, sig);
|
|
891
|
+
}
|
|
892
|
+
return sig;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function buildIpThrottleHeaders(freeTierMsg, diagnostic = null) {
|
|
896
|
+
if (!freeTierMsg) return { "Content-Type": "application/json" };
|
|
897
|
+
const retryAfterSec = Math.max(1, Math.ceil((GATE_RETRY_COOLDOWN_MS || 30000) / 1000));
|
|
898
|
+
const headers = {
|
|
899
|
+
"Content-Type": "application/json",
|
|
900
|
+
"Retry-After": String(retryAfterSec),
|
|
901
|
+
// `shape-ok` means the request carried every anchor the gate checks, so the
|
|
902
|
+
// refusal is about this egress. `shape-rejected` means the bridge produced
|
|
903
|
+
// a request the gate refuses — a bridge bug that returns the identical
|
|
904
|
+
// upstream error. Callers that alert on this can tell the two apart.
|
|
905
|
+
"X-OpenCode-Gate": diagnostic && diagnostic.shapeOk === false ? "shape-rejected" : "ip-throttled",
|
|
906
|
+
};
|
|
907
|
+
if (diagnostic && diagnostic.logId) headers["X-OpenCode-Log-Id"] = diagnostic.logId;
|
|
908
|
+
return headers;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function resolveIpThrottleStatus(upstreamStatus, freeTierMsg) {
|
|
912
|
+
if (freeTierMsg && process.env.IP_LIMIT_STATUS === "429") return 429;
|
|
913
|
+
return upstreamStatus;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Is this client-supplied tool already byte-identical to the fingerprint we
|
|
918
|
+
* would inject? If so it can be passed through untouched.
|
|
919
|
+
*/
|
|
920
|
+
function isExactFingerprintDef(tool, key) {
|
|
921
|
+
const want = identity.getTools()[key];
|
|
922
|
+
if (!want || !tool || typeof tool !== "object") return false;
|
|
923
|
+
const def = tool.function && typeof tool.function === "object" ? tool.function : tool;
|
|
924
|
+
return (
|
|
925
|
+
def.description === want.description &&
|
|
926
|
+
getParamSig(def.parameters) === getParamSig(want.parameters)
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function buildPlaceholderNote(appendedNames) {
|
|
931
|
+
if (!Array.isArray(appendedNames) || appendedNames.length === 0) return "";
|
|
932
|
+
const list = appendedNames.map((n) => `\`${n}\``).join(" or ");
|
|
933
|
+
return appendedNames.length > 1
|
|
934
|
+
? `Do not call the ${list} tools; they are placeholders that do nothing. Use the other tools you have been given instead.`
|
|
935
|
+
: `Do not call the ${list} tool; it is a placeholder that does nothing. Use the other tools you have been given instead.`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function getToolName(tool) {
|
|
939
|
+
if (!tool || typeof tool !== "object") return "";
|
|
940
|
+
if (typeof tool.name === "string") return tool.name.trim();
|
|
941
|
+
if (tool.function && typeof tool.function.name === "string") return tool.function.name.trim();
|
|
942
|
+
return "";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Merge the client's tools with the fingerprint tools the gate requires.
|
|
947
|
+
*
|
|
948
|
+
* Three cases per required tool:
|
|
949
|
+
* - client already sends it verbatim → keep the client's copy
|
|
950
|
+
* - client sends the same name with a different schema → rename the client's
|
|
951
|
+
* copy (`read_client`) so both survive, and map responses back
|
|
952
|
+
* - client does not send it at all → append the fingerprint definition
|
|
953
|
+
*
|
|
954
|
+
* The definitions are resolved per call from identity.js, so a profile update
|
|
955
|
+
* takes effect on the next request without a restart.
|
|
956
|
+
*/
|
|
957
|
+
function prepareToolsWithFingerprint(clientTools = [], flat = false, scope = null, modelId = null) {
|
|
958
|
+
const fingerprintNames = getFingerprintNames(scope, modelId);
|
|
959
|
+
const canonical = getCanonicalTools(flat, scope, modelId);
|
|
960
|
+
const canonicalByName = new Map(
|
|
961
|
+
canonical.map((t) => [flat ? t.name : t.function.name, t])
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
if (!Array.isArray(clientTools) || clientTools.length === 0) {
|
|
965
|
+
return {
|
|
966
|
+
tools: canonical,
|
|
967
|
+
appended: fingerprintNames,
|
|
968
|
+
placeholderNote: buildPlaceholderNote(fingerprintNames),
|
|
969
|
+
aliasToOriginal: EMPTY_MAP,
|
|
970
|
+
originalToAlias: EMPTY_MAP,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const aliasToOriginal = new Map();
|
|
975
|
+
const originalToAlias = new Map();
|
|
976
|
+
const normalized = [];
|
|
977
|
+
const takenNames = new Set(clientTools.map(getToolName).filter(Boolean));
|
|
978
|
+
const presentExactFingerprints = new Set();
|
|
979
|
+
|
|
980
|
+
for (const t of clientTools) {
|
|
981
|
+
const name = getToolName(t);
|
|
982
|
+
if (!name) continue;
|
|
983
|
+
const def = t.function && typeof t.function === "object" ? t.function : t;
|
|
984
|
+
const description = def.description || "";
|
|
985
|
+
const parameters = def.parameters || { type: "object", properties: {} };
|
|
986
|
+
|
|
987
|
+
if (fingerprintNames.includes(name)) {
|
|
988
|
+
if (presentExactFingerprints.has(name)) continue;
|
|
989
|
+
if (isExactFingerprintDef(t, name)) {
|
|
990
|
+
presentExactFingerprints.add(name);
|
|
991
|
+
normalized.push(
|
|
992
|
+
flat
|
|
993
|
+
? { type: "function", name, description, parameters, strict: false }
|
|
994
|
+
: { type: "function", function: { name, description, parameters } }
|
|
995
|
+
);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
// Client defined its own tool under a name the gate fingerprints: alias
|
|
999
|
+
// it so the exact fingerprint can still be injected alongside.
|
|
1000
|
+
if (originalToAlias.has(name)) continue;
|
|
1001
|
+
let alias = `${name}_client`;
|
|
1002
|
+
for (let i = 2; takenNames.has(alias); i++) {
|
|
1003
|
+
alias = `${name}_client${i}`;
|
|
1004
|
+
}
|
|
1005
|
+
takenNames.add(alias);
|
|
1006
|
+
aliasToOriginal.set(alias, name);
|
|
1007
|
+
originalToAlias.set(name, alias);
|
|
1008
|
+
normalized.push(
|
|
1009
|
+
flat
|
|
1010
|
+
? { type: "function", name: alias, description, parameters }
|
|
1011
|
+
: { type: "function", function: { name: alias, description, parameters } }
|
|
1012
|
+
);
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
normalized.push(
|
|
1017
|
+
flat
|
|
1018
|
+
? { type: "function", name, description, parameters }
|
|
1019
|
+
: { type: "function", function: { name, description, parameters } }
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const appended = [];
|
|
1024
|
+
for (const name of fingerprintNames) {
|
|
1025
|
+
if (presentExactFingerprints.has(name)) continue;
|
|
1026
|
+
const def = canonicalByName.get(name);
|
|
1027
|
+
if (!def) continue;
|
|
1028
|
+
normalized.push(def);
|
|
1029
|
+
appended.push(name);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return {
|
|
1033
|
+
tools: normalized,
|
|
1034
|
+
appended,
|
|
1035
|
+
placeholderNote:
|
|
1036
|
+
appended.length === fingerprintNames.length
|
|
1037
|
+
? buildPlaceholderNote(fingerprintNames)
|
|
1038
|
+
: buildPlaceholderNote(appended),
|
|
1039
|
+
aliasToOriginal,
|
|
1040
|
+
originalToAlias,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function prepareResponsesTools(clientTools = [], modelId = null) {
|
|
1045
|
+
return prepareToolsWithFingerprint(clientTools, true, null, modelId).tools;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function prepareChatTools(clientTools = [], modelId = null) {
|
|
1049
|
+
return prepareToolsWithFingerprint(clientTools, false, null, modelId).tools;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function restoreClientToolName(name, aliasToOriginal) {
|
|
1053
|
+
if (!name || !aliasToOriginal || aliasToOriginal.size === 0) return name;
|
|
1054
|
+
return aliasToOriginal.get(name) || name;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function extractContent(content) {
|
|
1058
|
+
if (typeof content === "string") return content;
|
|
1059
|
+
if (Array.isArray(content)) {
|
|
1060
|
+
return content
|
|
1061
|
+
.map((p) => {
|
|
1062
|
+
if (typeof p === "string") return p;
|
|
1063
|
+
if (p?.type === "text" && typeof p.text === "string") return p.text;
|
|
1064
|
+
if (p?.text) return String(p.text);
|
|
1065
|
+
return "";
|
|
1066
|
+
})
|
|
1067
|
+
.filter(Boolean)
|
|
1068
|
+
.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
return content ? JSON.stringify(content) : "";
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// First-N-chars text without ever serializing the whole value: strings are
|
|
1074
|
+
// sliced directly, arrays join only text parts up to the limit, objects fall
|
|
1075
|
+
// back to a bounded stringify. Used for the headerless session signature so
|
|
1076
|
+
// a multi-MB system prompt is never JSON.stringified just for a hash.
|
|
1077
|
+
function shortText(content, limit = 1000) {
|
|
1078
|
+
if (typeof content === "string") return content.slice(0, limit);
|
|
1079
|
+
if (Array.isArray(content)) {
|
|
1080
|
+
let out = "";
|
|
1081
|
+
for (const p of content) {
|
|
1082
|
+
if (out.length >= limit) break;
|
|
1083
|
+
if (typeof p === "string") out += p;
|
|
1084
|
+
else if (p?.type === "text" && typeof p.text === "string") out += p.text;
|
|
1085
|
+
else if (p?.text) out += String(p.text);
|
|
1086
|
+
}
|
|
1087
|
+
return out.slice(0, limit);
|
|
1088
|
+
}
|
|
1089
|
+
if (!content) return "";
|
|
1090
|
+
try {
|
|
1091
|
+
return JSON.stringify(content).slice(0, limit);
|
|
1092
|
+
} catch {
|
|
1093
|
+
return String(content).slice(0, limit);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function messageSignatureSample(content, limit = 1000) {
|
|
1098
|
+
if (typeof content === "string") {
|
|
1099
|
+
const len = content.length;
|
|
1100
|
+
if (len <= limit) return `${len}:${content}`;
|
|
1101
|
+
const half = Math.floor(limit / 2);
|
|
1102
|
+
return `${len}:${content.slice(0, half)}..${content.slice(len - half)}`;
|
|
1103
|
+
}
|
|
1104
|
+
if (Array.isArray(content)) {
|
|
1105
|
+
const head = shortText(content, Math.floor(limit / 2));
|
|
1106
|
+
const last = content[content.length - 1];
|
|
1107
|
+
const tail = shortText(last ? [last] : [], Math.floor(limit / 2));
|
|
1108
|
+
return `${content.length}:${head}..${tail}`;
|
|
1109
|
+
}
|
|
1110
|
+
return shortText(content, limit);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function transformResponsesContent(content, role = "user") {
|
|
1114
|
+
if (typeof content === "string") return content;
|
|
1115
|
+
if (Array.isArray(content)) {
|
|
1116
|
+
const hasNonText = content.some(
|
|
1117
|
+
(p) =>
|
|
1118
|
+
p &&
|
|
1119
|
+
typeof p === "object" &&
|
|
1120
|
+
(p.type === "image_url" || p.type === "input_image" || p.type === "input_file")
|
|
1121
|
+
);
|
|
1122
|
+
if (!hasNonText) return extractContent(content);
|
|
1123
|
+
const textType = role === "assistant" ? "output_text" : "input_text";
|
|
1124
|
+
const parts = [];
|
|
1125
|
+
for (const p of content) {
|
|
1126
|
+
if (typeof p === "string") {
|
|
1127
|
+
parts.push({ type: textType, text: p });
|
|
1128
|
+
} else if (p && typeof p === "object") {
|
|
1129
|
+
if (p.type === "text" || p.type === "input_text" || p.type === "output_text") {
|
|
1130
|
+
parts.push({ type: textType, text: String(p.text ?? "") });
|
|
1131
|
+
} else if (p.type === "image_url") {
|
|
1132
|
+
const imageUrl = typeof p.image_url === "string" ? p.image_url : p.image_url?.url;
|
|
1133
|
+
if (imageUrl) parts.push({ type: "input_image", image_url: imageUrl });
|
|
1134
|
+
} else if (p.type === "input_image" || p.type === "input_file") {
|
|
1135
|
+
parts.push(p);
|
|
1136
|
+
} else if (p.text) {
|
|
1137
|
+
parts.push({ type: textType, text: String(p.text) });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return parts;
|
|
1142
|
+
}
|
|
1143
|
+
return extractContent(content);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Convert OpenAI messages to OpenCode input format (for Responses API)
|
|
1147
|
+
function transformToResponsesInput(messages = [], originalToAlias = null) {
|
|
1148
|
+
const input = [];
|
|
1149
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
1150
|
+
if (!m) continue;
|
|
1151
|
+
if (m.role === "system" || m.role === "developer") {
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
if (m.role === "tool") {
|
|
1155
|
+
input.push({
|
|
1156
|
+
type: "function_call_output",
|
|
1157
|
+
call_id: m.tool_call_id || "call_unknown",
|
|
1158
|
+
output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
|
|
1159
|
+
});
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
1163
|
+
if (m.content) {
|
|
1164
|
+
input.push({
|
|
1165
|
+
role: "assistant",
|
|
1166
|
+
content: transformResponsesContent(m.content, "assistant"),
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
for (const tc of m.tool_calls) {
|
|
1170
|
+
const fn = tc.function || {};
|
|
1171
|
+
const rawName = fn.name || "unknown";
|
|
1172
|
+
const mappedName =
|
|
1173
|
+
originalToAlias && originalToAlias.has(rawName) ? originalToAlias.get(rawName) : rawName;
|
|
1174
|
+
input.push({
|
|
1175
|
+
type: "function_call",
|
|
1176
|
+
call_id: tc.id || `call_${crypto.randomBytes(8).toString("hex")}`,
|
|
1177
|
+
name: mappedName,
|
|
1178
|
+
arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const role = m.role || "user";
|
|
1184
|
+
input.push({ role, content: transformResponsesContent(m.content, role) });
|
|
1185
|
+
}
|
|
1186
|
+
return input;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* The harness environment block the real CLI appends to every system prompt.
|
|
1191
|
+
*
|
|
1192
|
+
* Measured against the live gateway on 2026-10-25: a request whose system
|
|
1193
|
+
* prompt does not contain this block is answered with `429 FreeUsageLimitError`,
|
|
1194
|
+
* byte-for-byte the message the gateway uses for an exhausted allowance. The
|
|
1195
|
+
* trigger is the `<env>` block with a `Working directory:` line, not the prompt
|
|
1196
|
+
* length and not the model: a 12 KB generic prompt is refused, a 65-byte
|
|
1197
|
+
* `<env>Working directory: /tmp</env>` is accepted, and the same split holds
|
|
1198
|
+
* for big-pickle and mimo. That made a *shape* problem look like a quota
|
|
1199
|
+
* problem — the CLI answered while the bridge did not, in the same minute.
|
|
1200
|
+
*
|
|
1201
|
+
* The CLI builds this from its own session context, so the values here are the
|
|
1202
|
+
* bridge's honest equivalents: its working directory and whether that is a git
|
|
1203
|
+
* repository. Reproducing the block is what makes the bridge's request
|
|
1204
|
+
* indistinguishable from the CLI's on this gate.
|
|
1205
|
+
*
|
|
1206
|
+
* Cached per working directory: the values cannot change while the process runs
|
|
1207
|
+
* (the bridge never chdir's), and probing them costs a `git` subprocess —
|
|
1208
|
+
* measured at 2.5 ms, which is real money on every request.
|
|
1209
|
+
*/
|
|
1210
|
+
let harnessEnvBlockCache = { cwd: null, block: "" };
|
|
1211
|
+
function buildHarnessEnvBlock() {
|
|
1212
|
+
const cwd = process.cwd();
|
|
1213
|
+
if (harnessEnvBlockCache.cwd === cwd && harnessEnvBlockCache.block) {
|
|
1214
|
+
return harnessEnvBlockCache.block;
|
|
1215
|
+
}
|
|
1216
|
+
let isRepo = "no";
|
|
1217
|
+
try {
|
|
1218
|
+
const { execFileSync } = require("child_process");
|
|
1219
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
1220
|
+
cwd,
|
|
1221
|
+
encoding: "utf8",
|
|
1222
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1223
|
+
timeout: 2000,
|
|
1224
|
+
});
|
|
1225
|
+
isRepo = "yes";
|
|
1226
|
+
} catch {}
|
|
1227
|
+
const block = [
|
|
1228
|
+
"Here is some useful information about the environment you are running in:",
|
|
1229
|
+
"<env>",
|
|
1230
|
+
` Working directory: ${cwd}`,
|
|
1231
|
+
` Workspace root folder: ${cwd}`,
|
|
1232
|
+
` Is directory a git repo: ${isRepo}`,
|
|
1233
|
+
" Platform: " + process.platform,
|
|
1234
|
+
"</env>",
|
|
1235
|
+
].join("\n");
|
|
1236
|
+
harnessEnvBlockCache = { cwd, block };
|
|
1237
|
+
return block;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Append the harness env block to a system prompt unless the client already
|
|
1241
|
+
* sent one. A client that forwards the CLI's own prompt (or any prompt with an
|
|
1242
|
+
* `<env>` block) keeps it verbatim — only the missing anchor is added.
|
|
1243
|
+
*/
|
|
1244
|
+
function withHarnessEnvBlock(systemText) {
|
|
1245
|
+
const text = typeof systemText === "string" ? systemText : "";
|
|
1246
|
+
if (/<env>[\s\S]*?Working directory:/.test(text)) return text;
|
|
1247
|
+
const block = buildHarnessEnvBlock();
|
|
1248
|
+
return text.trim() ? `${text.replace(/\s+$/, "")}\n\n${block}` : block;
|
|
1249
|
+
}
|
|
1250
|
+
function extractSystemInstructions(messages = [], explicitInstructions = "", placeholderNote = "") {
|
|
1251
|
+
const parts = [];
|
|
1252
|
+
if (typeof explicitInstructions === "string" && explicitInstructions.trim()) {
|
|
1253
|
+
parts.push(explicitInstructions.trim());
|
|
1254
|
+
}
|
|
1255
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
1256
|
+
if (!m || (m.role !== "system" && m.role !== "developer")) continue;
|
|
1257
|
+
const text = extractContent(m.content).trim();
|
|
1258
|
+
if (text) parts.push(text);
|
|
1259
|
+
}
|
|
1260
|
+
if (placeholderNote) {
|
|
1261
|
+
parts.push(placeholderNote);
|
|
1262
|
+
}
|
|
1263
|
+
return withHarnessEnvBlock(parts.join("\n\n"));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function transformChatMessages(messages = [], originalToAlias = null, placeholderNote = "") {
|
|
1267
|
+
const out = [];
|
|
1268
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
1269
|
+
if (!m) continue;
|
|
1270
|
+
if (
|
|
1271
|
+
originalToAlias &&
|
|
1272
|
+
originalToAlias.size > 0 &&
|
|
1273
|
+
m.role === "assistant" &&
|
|
1274
|
+
Array.isArray(m.tool_calls)
|
|
1275
|
+
) {
|
|
1276
|
+
out.push({
|
|
1277
|
+
...m,
|
|
1278
|
+
tool_calls: m.tool_calls.map((tc) => {
|
|
1279
|
+
const fnName = tc?.function?.name;
|
|
1280
|
+
if (fnName && originalToAlias.has(fnName)) {
|
|
1281
|
+
return { ...tc, function: { ...tc.function, name: originalToAlias.get(fnName) } };
|
|
1282
|
+
}
|
|
1283
|
+
return tc;
|
|
1284
|
+
}),
|
|
1285
|
+
});
|
|
1286
|
+
} else {
|
|
1287
|
+
out.push(m);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (placeholderNote) {
|
|
1292
|
+
const first = out[0];
|
|
1293
|
+
if (first?.role === "system" || first?.role === "developer") {
|
|
1294
|
+
if (typeof first.content === "string") {
|
|
1295
|
+
out[0] = { ...first, content: `${first.content}\n\n${placeholderNote}` };
|
|
1296
|
+
} else if (Array.isArray(first.content)) {
|
|
1297
|
+
out[0] = { ...first, content: [...first.content, { type: "text", text: `\n\n${placeholderNote}` }] };
|
|
1298
|
+
} else {
|
|
1299
|
+
out.unshift({ role: "system", content: placeholderNote });
|
|
1300
|
+
}
|
|
1301
|
+
} else {
|
|
1302
|
+
out.unshift({ role: "system", content: placeholderNote });
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// The gateway refuses a request whose system prompt lacks the harness `<env>`
|
|
1307
|
+
// block with the same `429 FreeUsageLimitError` it uses for an exhausted
|
|
1308
|
+
// allowance. See buildHarnessEnvBlock. A client that already forwarded the
|
|
1309
|
+
// CLI's prompt keeps its own block; only the missing anchor is added.
|
|
1310
|
+
const sysIndex = out.findIndex((m) => m?.role === "system" || m?.role === "developer");
|
|
1311
|
+
if (sysIndex === -1) {
|
|
1312
|
+
out.unshift({ role: "system", content: buildHarnessEnvBlock() });
|
|
1313
|
+
} else {
|
|
1314
|
+
const sys = out[sysIndex];
|
|
1315
|
+
if (typeof sys.content === "string") {
|
|
1316
|
+
out[sysIndex] = { ...sys, content: withHarnessEnvBlock(sys.content) };
|
|
1317
|
+
} else if (Array.isArray(sys.content)) {
|
|
1318
|
+
const text = extractContent(sys.content);
|
|
1319
|
+
if (!/<env>[\s\S]*?Working directory:/.test(text)) {
|
|
1320
|
+
out[sysIndex] = {
|
|
1321
|
+
...sys,
|
|
1322
|
+
content: [...sys.content, { type: "text", text: `\n\n${buildHarnessEnvBlock()}` }],
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
} else {
|
|
1326
|
+
out[sysIndex] = { ...sys, content: buildHarnessEnvBlock() };
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return out;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function buildUpstreamPayload(body, targetModel, useResponses, scope = null) {
|
|
1333
|
+
const requestedTokens =
|
|
1334
|
+
body.max_output_tokens ?? body.max_completion_tokens ?? body.max_tokens;
|
|
1335
|
+
const maxTokens = effectiveMaxTokens(requestedTokens, targetModel, body);
|
|
1336
|
+
const { tools, placeholderNote, aliasToOriginal, originalToAlias } = prepareToolsWithFingerprint(
|
|
1337
|
+
body.tools,
|
|
1338
|
+
useResponses,
|
|
1339
|
+
scope,
|
|
1340
|
+
targetModel
|
|
1341
|
+
);
|
|
1342
|
+
if (useResponses) {
|
|
1343
|
+
const upstreamBody = {
|
|
1344
|
+
model: targetModel,
|
|
1345
|
+
input: transformToResponsesInput(body.messages, originalToAlias),
|
|
1346
|
+
stream: true,
|
|
1347
|
+
max_output_tokens: maxTokens,
|
|
1348
|
+
tools,
|
|
1349
|
+
tool_choice: "auto",
|
|
1350
|
+
};
|
|
1351
|
+
const instructions = extractSystemInstructions(
|
|
1352
|
+
body.messages,
|
|
1353
|
+
body.instructions,
|
|
1354
|
+
placeholderNote
|
|
1355
|
+
);
|
|
1356
|
+
if (instructions) {
|
|
1357
|
+
upstreamBody.instructions = instructions;
|
|
1358
|
+
}
|
|
1359
|
+
if (typeof body.temperature === "number") upstreamBody.temperature = body.temperature;
|
|
1360
|
+
if (typeof body.top_p === "number") upstreamBody.top_p = body.top_p;
|
|
1361
|
+
return { upstreamBody, aliasToOriginal };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Field names and order match the CLI's own request: `max_completion_tokens`
|
|
1365
|
+
// (not the legacy `max_tokens`), `store: false`, and `tools` before `stream`.
|
|
1366
|
+
// Upstream accepts both spellings today, but sending the CLI's exact shape
|
|
1367
|
+
// removes a difference that costs nothing to erase.
|
|
1368
|
+
const upstreamBody = {
|
|
1369
|
+
model: targetModel,
|
|
1370
|
+
messages: transformChatMessages(body.messages, originalToAlias, placeholderNote),
|
|
1371
|
+
tools,
|
|
1372
|
+
stream: true,
|
|
1373
|
+
stream_options: { include_usage: true },
|
|
1374
|
+
max_completion_tokens: maxTokens,
|
|
1375
|
+
store: false,
|
|
1376
|
+
};
|
|
1377
|
+
if (typeof body.temperature === "number") upstreamBody.temperature = body.temperature;
|
|
1378
|
+
if (typeof body.top_p === "number") upstreamBody.top_p = body.top_p;
|
|
1379
|
+
if (body.stop !== undefined) upstreamBody.stop = body.stop;
|
|
1380
|
+
if (body.response_format !== undefined) upstreamBody.response_format = body.response_format;
|
|
1381
|
+
if (body.reasoning_effort !== undefined) upstreamBody.reasoning_effort = body.reasoning_effort;
|
|
1382
|
+
|
|
1383
|
+
// Upstream v2 rejects tool_choice: "none" with 403 FreeTierError.
|
|
1384
|
+
if (body.tool_choice && body.tool_choice !== "none") {
|
|
1385
|
+
if (
|
|
1386
|
+
typeof body.tool_choice === "object" &&
|
|
1387
|
+
body.tool_choice.function?.name &&
|
|
1388
|
+
originalToAlias.has(body.tool_choice.function.name)
|
|
1389
|
+
) {
|
|
1390
|
+
upstreamBody.tool_choice = {
|
|
1391
|
+
...body.tool_choice,
|
|
1392
|
+
function: {
|
|
1393
|
+
...body.tool_choice.function,
|
|
1394
|
+
name: originalToAlias.get(body.tool_choice.function.name),
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
} else {
|
|
1398
|
+
upstreamBody.tool_choice = body.tool_choice;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return { upstreamBody, aliasToOriginal };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Mint a fresh session id after the identity changed.
|
|
1407
|
+
*
|
|
1408
|
+
* The id is derived from the recipe, so an id minted *before* a refresh encodes
|
|
1409
|
+
* the shape the gate just rejected. The self-heal path therefore has to replace
|
|
1410
|
+
* it — replaying the stale id would send the same rejected request and fail
|
|
1411
|
+
* identically. The cache entry is dropped for the same reason: otherwise the
|
|
1412
|
+
* next request from this client would get the stale id back.
|
|
1413
|
+
*/
|
|
1414
|
+
function regenerateSessionId(req, body, staleId) {
|
|
1415
|
+
for (const [key, entry] of sessionCache) {
|
|
1416
|
+
if (entry.sessionId === staleId) sessionCache.delete(key);
|
|
1417
|
+
}
|
|
1418
|
+
return getPersistentSessionId(req, body);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* The project id the CLI would send from this checkout.
|
|
1423
|
+
*
|
|
1424
|
+
* Measured against a real CLI capture: the header equals
|
|
1425
|
+
* `sha1("git-remote:github.com/<owner>/<repo>")` — the remote URL with scheme
|
|
1426
|
+
* and `.git` stripped. With no remote the CLI hashes the directory path
|
|
1427
|
+
* (`sha1("directory:<abs path>")`), and an `opencode` file in the directory
|
|
1428
|
+
* overrides both. Derived once and cached: a checkout does not move.
|
|
1429
|
+
*/
|
|
1430
|
+
let cachedProjectId = null;
|
|
1431
|
+
function getProjectId() {
|
|
1432
|
+
if (cachedProjectId) return cachedProjectId;
|
|
1433
|
+
try {
|
|
1434
|
+
const fs = require("fs");
|
|
1435
|
+
const path = require("path");
|
|
1436
|
+
const crypto = require("crypto");
|
|
1437
|
+
const cwd = process.cwd();
|
|
1438
|
+
|
|
1439
|
+
// An `opencode` file wins, exactly as it does in the CLI.
|
|
1440
|
+
const marker = path.join(cwd, "opencode");
|
|
1441
|
+
if (fs.existsSync(marker)) {
|
|
1442
|
+
const value = fs.readFileSync(marker, "utf8").trim();
|
|
1443
|
+
if (value) return (cachedProjectId = value);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Git remote, normalised the way the CLI's `url()` helper does it.
|
|
1447
|
+
let remote = "";
|
|
1448
|
+
try {
|
|
1449
|
+
const { execFileSync } = require("child_process");
|
|
1450
|
+
remote = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
1451
|
+
cwd,
|
|
1452
|
+
encoding: "utf8",
|
|
1453
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1454
|
+
timeout: 2000,
|
|
1455
|
+
}).trim();
|
|
1456
|
+
} catch {}
|
|
1457
|
+
if (remote) {
|
|
1458
|
+
const normalized = remote
|
|
1459
|
+
.replace(/^[a-z]+:\/\//i, "") // drop scheme
|
|
1460
|
+
.replace(/^git@([^:]+):/i, "$1/") // scp-style
|
|
1461
|
+
.replace(/\.git$/i, "")
|
|
1462
|
+
.replace(/\/+$/, "");
|
|
1463
|
+
if (normalized) {
|
|
1464
|
+
return (cachedProjectId = crypto
|
|
1465
|
+
.createHash("sha1")
|
|
1466
|
+
.update(`git-remote:${normalized}`)
|
|
1467
|
+
.digest("hex"));
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
return (cachedProjectId = crypto
|
|
1472
|
+
.createHash("sha1")
|
|
1473
|
+
.update(`directory:${cwd}`)
|
|
1474
|
+
.digest("hex"));
|
|
1475
|
+
} catch {
|
|
1476
|
+
return (cachedProjectId = "global");
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function buildUpstreamHeaders(req, sessionId) {
|
|
1481
|
+
const downstreamUa = req?.headers?.["user-agent"] || "";
|
|
1482
|
+
const ua = isValidV2UserAgent(downstreamUa) ? downstreamUa : gateSafeUserAgent();
|
|
1483
|
+
const client = req?.headers?.["x-opencode-client"];
|
|
1484
|
+
// `x-opencode-project` is not a constant: the CLI derives it as
|
|
1485
|
+
// `sha1("git-remote:" + host + path)` for a repo, `sha1("directory:" + path)`
|
|
1486
|
+
// otherwise, or the contents of an `opencode` file in the directory. Sending
|
|
1487
|
+
// the literal "global" is a shape the CLI never emits, so it is derived here
|
|
1488
|
+
// the same way — from this bridge's own checkout. A client that supplies the
|
|
1489
|
+
// header still wins.
|
|
1490
|
+
const project = req?.headers?.["x-opencode-project"] || getProjectId();
|
|
1491
|
+
// The gate header set is derived by identity.js from upstream's own request
|
|
1492
|
+
// builder, so a header added upstream starts being sent on the next request
|
|
1493
|
+
// after a refresh. Values this bridge cannot derive are reported, not sent
|
|
1494
|
+
// empty: an empty required header is worse than an absent one.
|
|
1495
|
+
const { headers: gateHeaders } = identity.buildGateHeaders({
|
|
1496
|
+
userAgent: ua,
|
|
1497
|
+
client: client === "desktop" || client === "cli" ? client : "cli",
|
|
1498
|
+
project: String(project),
|
|
1499
|
+
sessionId,
|
|
1500
|
+
});
|
|
1501
|
+
return {
|
|
1502
|
+
"Authorization": "Bearer public",
|
|
1503
|
+
"Content-Type": "application/json",
|
|
1504
|
+
// The CLI sends `*/*` here (it always streams via the body, not the Accept
|
|
1505
|
+
// header). Matching it costs nothing and removes a difference the gate
|
|
1506
|
+
// could one day start looking at.
|
|
1507
|
+
"Accept": "*/*",
|
|
1508
|
+
// W3C trace context, which the CLI emits on every request. The values are
|
|
1509
|
+
// random per request — measured: they carry no relation to the session id —
|
|
1510
|
+
// so this generates them the same way rather than deriving them. Both
|
|
1511
|
+
// headers describe one 128-bit trace with a 64-bit span; `b3` adds the
|
|
1512
|
+
// parent span id.
|
|
1513
|
+
...buildTraceHeaders(),
|
|
1514
|
+
...gateHeaders,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** A fresh W3C trace context, shaped like the CLI's. */
|
|
1519
|
+
function buildTraceHeaders() {
|
|
1520
|
+
const traceId = crypto.randomBytes(16).toString("hex");
|
|
1521
|
+
const spanId = crypto.randomBytes(8).toString("hex");
|
|
1522
|
+
const parentSpanId = crypto.randomBytes(8).toString("hex");
|
|
1523
|
+
return {
|
|
1524
|
+
b3: `${traceId}-${spanId}-1-${parentSpanId}`,
|
|
1525
|
+
traceparent: `00-${traceId}-${spanId}-01`,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Upstream can answer HTTP 200 and then report a failure *inside* the stream
|
|
1531
|
+
* (`data: {"error":{...}}`, e.g. "Streaming response failed: [503] ...").
|
|
1532
|
+
* Left unhandled, a non-streaming client is handed a 200 with `content: null`
|
|
1533
|
+
* and `finish_reason: "stop"` — a fake success. Returns the error text when the
|
|
1534
|
+
* event is an error, otherwise null.
|
|
1535
|
+
*/
|
|
1536
|
+
function extractStreamError(ev) {
|
|
1537
|
+
const err = ev?.error;
|
|
1538
|
+
if (!err) return null;
|
|
1539
|
+
if (typeof err === "string") return err;
|
|
1540
|
+
return err.message || err.type || JSON.stringify(err);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function extractResponsesFinishReason(ev, hasToolCalls) {
|
|
1544
|
+
const status = ev?.response?.status;
|
|
1545
|
+
const reason = ev?.response?.incomplete_details?.reason;
|
|
1546
|
+
if (ev?.type === "response.incomplete" || status === "incomplete") {
|
|
1547
|
+
if (reason === "content_filter") return "content_filter";
|
|
1548
|
+
return "length";
|
|
1549
|
+
}
|
|
1550
|
+
if (hasToolCalls) return "tool_calls";
|
|
1551
|
+
return "stop";
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Persistent session cache — preserves upstream prompt/KV cache across turns (LRU)
|
|
1555
|
+
const sessionCache = new Map();
|
|
1556
|
+
const MAX_SESSIONS = 500;
|
|
1557
|
+
const SESSION_TTL_MS = 3 * 3600 * 1000;
|
|
1558
|
+
let lastFailedGateRetryTime = 0;
|
|
1559
|
+
let lastFailedGateRetryEpoch = -1;
|
|
1560
|
+
// Last IP-level abuse block seen from upstream, if any. Kept apart from the
|
|
1561
|
+
// retry cooldown because the two have different causes and different advice:
|
|
1562
|
+
// the cooldown is a per-IP allowance window, the block is the gateway refusing
|
|
1563
|
+
// this network outright.
|
|
1564
|
+
let lastIpAbuseAt = 0;
|
|
1565
|
+
// The last gate rejection's correlation id and the shape audit that was true
|
|
1566
|
+
// when it happened. `x-opencode-log-id` is the only handle upstream gives for
|
|
1567
|
+
// a rejected request, so it is recorded rather than discarded — it is what a
|
|
1568
|
+
// support request or a bug report needs to be actionable.
|
|
1569
|
+
let lastGateDiagnostic = null;
|
|
1570
|
+
let lastUpstreamError = null;
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Which gate anchors the *actual* outgoing request carried.
|
|
1574
|
+
*
|
|
1575
|
+
* The gate requires a tool named `read` and an `opencode/*` User-Agent with a
|
|
1576
|
+
* session header (see GATE_ANCHOR_TOOL_NAMES / GATE_ANCHOR_HEADER_NAMES in
|
|
1577
|
+
* identity.js). When a 403 comes back, these are what distinguishes "this
|
|
1578
|
+
* bridge sent an unacceptable shape" from "upstream refuses this egress".
|
|
1579
|
+
* Reporting the distinction is the whole point: both produce an identical
|
|
1580
|
+
* FreeTierError body.
|
|
1581
|
+
*/
|
|
1582
|
+
function auditGateShape(upstreamBody, headers) {
|
|
1583
|
+
const tools = Array.isArray(upstreamBody?.tools) ? upstreamBody.tools : [];
|
|
1584
|
+
const toolNames = tools.map((t) => t?.function?.name || t?.name).filter(Boolean);
|
|
1585
|
+
// Header names are compared case-insensitively: the audit must not report a
|
|
1586
|
+
// missing anchor just because the same header arrived with different casing.
|
|
1587
|
+
const headerKey = (name) =>
|
|
1588
|
+
Object.keys(headers || {}).find((k) => k.toLowerCase() === name);
|
|
1589
|
+
const hasHeader = (name) => headerKey(name) !== undefined;
|
|
1590
|
+
const ua = String(headers?.[headerKey("user-agent")] || "");
|
|
1591
|
+
const sessionHeaders = ["x-opencode-session", "x-session-affinity", "x-session-id"].filter(
|
|
1592
|
+
hasHeader
|
|
1593
|
+
);
|
|
1594
|
+
return {
|
|
1595
|
+
hasReadTool: toolNames.includes("read"),
|
|
1596
|
+
toolCount: toolNames.length,
|
|
1597
|
+
userAgentOk: /^opencode\/[A-Za-z0-9-]+\/\d+\.\d+\.\d+\/[A-Za-z0-9-]+$/i.test(ua),
|
|
1598
|
+
userAgent: ua,
|
|
1599
|
+
sessionHeaders,
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function upstreamLogIdOf(upstreamRes) {
|
|
1604
|
+
return typeof upstreamRes?.headers?.get === "function"
|
|
1605
|
+
? upstreamRes.headers.get("x-opencode-log-id")
|
|
1606
|
+
: null;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Answer an upstream IP-level abuse block.
|
|
1611
|
+
*
|
|
1612
|
+
* Terminal by construction: the block is keyed to the egress network, so the
|
|
1613
|
+
* identity layer is not consulted, no session id is re-minted, and the request
|
|
1614
|
+
* is not replayed — measured (and confirmed by upstream's own issue tracker)
|
|
1615
|
+
* as a network flag that no client shape can clear. The refusal reaches the
|
|
1616
|
+
* client verbatim; `X-OpenCode-Gate: ip-abuse` lets callers and `verify.js`
|
|
1617
|
+
* tell it apart from the shape gate without parsing the message.
|
|
1618
|
+
*/
|
|
1619
|
+
function respondIpAbuse(res, status, errType, errMsg, logId, model) {
|
|
1620
|
+
lastIpAbuseAt = Date.now();
|
|
1621
|
+
lastGateDiagnostic = {
|
|
1622
|
+
at: lastIpAbuseAt,
|
|
1623
|
+
kind: "ip-abuse",
|
|
1624
|
+
status,
|
|
1625
|
+
logId: logId || null,
|
|
1626
|
+
message: errMsg,
|
|
1627
|
+
model,
|
|
1628
|
+
};
|
|
1629
|
+
console.warn(
|
|
1630
|
+
`[opencode-free-bridge] Upstream IP-abuse block (${status}): ${errMsg} — not refreshing or retrying; the gateway keys this to this network's IP, so no identity change can clear it` +
|
|
1631
|
+
(logId ? ` [x-opencode-log-id: ${logId}]` : "")
|
|
1632
|
+
);
|
|
1633
|
+
res.writeHead(status, {
|
|
1634
|
+
"Content-Type": "application/json",
|
|
1635
|
+
"X-OpenCode-Gate": "ip-abuse",
|
|
1636
|
+
...(logId ? { "X-OpenCode-Log-Id": logId } : {}),
|
|
1637
|
+
});
|
|
1638
|
+
return res.end(
|
|
1639
|
+
JSON.stringify({
|
|
1640
|
+
error: {
|
|
1641
|
+
message: errMsg,
|
|
1642
|
+
type: errType || "FreeTierError",
|
|
1643
|
+
code: status,
|
|
1644
|
+
},
|
|
1645
|
+
})
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const origSessionCacheClear = sessionCache.clear.bind(sessionCache);
|
|
1650
|
+
sessionCache.clear = () => {
|
|
1651
|
+
lastFailedGateRetryTime = 0;
|
|
1652
|
+
lastFailedGateRetryEpoch = -1;
|
|
1653
|
+
lastIpAbuseAt = 0;
|
|
1654
|
+
lastUpstreamError = null;
|
|
1655
|
+
return origSessionCacheClear();
|
|
1656
|
+
};
|
|
1657
|
+
|
|
1658
|
+
function getPersistentSessionId(req, body) {
|
|
1659
|
+
const headers = req?.headers || {};
|
|
1660
|
+
const rawHeaderKey =
|
|
1661
|
+
headers["x-opencode-session"] ||
|
|
1662
|
+
headers["x-opencode-session-id"] ||
|
|
1663
|
+
headers["x-session-id"] ||
|
|
1664
|
+
headers["session-id"] ||
|
|
1665
|
+
headers["x-conversation-id"] ||
|
|
1666
|
+
headers["x-9r-session-id"];
|
|
1667
|
+
const headerKey = Array.isArray(rawHeaderKey)
|
|
1668
|
+
? String(rawHeaderKey[0] || "").slice(0, 256)
|
|
1669
|
+
: rawHeaderKey
|
|
1670
|
+
? String(rawHeaderKey).slice(0, 256)
|
|
1671
|
+
: "";
|
|
1672
|
+
// Fast path (common case): a session header is present, so no message
|
|
1673
|
+
// hashing is needed at all. The conv-signature hash below only runs for
|
|
1674
|
+
// headerless clients, and slices BEFORE stringify so a huge system prompt
|
|
1675
|
+
// never gets serialized just to take its first 1000 chars.
|
|
1676
|
+
let lookupKey = headerKey ? "hdr:" + headerKey : null;
|
|
1677
|
+
if (!lookupKey) {
|
|
1678
|
+
const msgs = body && Array.isArray(body.messages) ? body.messages : [];
|
|
1679
|
+
if (msgs.length > 0) {
|
|
1680
|
+
const sig =
|
|
1681
|
+
messageSignatureSample(msgs[0]?.content, 1000) +
|
|
1682
|
+
"::" +
|
|
1683
|
+
messageSignatureSample(msgs[1]?.content, 1000);
|
|
1684
|
+
if (sig !== "::") {
|
|
1685
|
+
lookupKey =
|
|
1686
|
+
"conv:" + crypto.createHash("sha256").update(sig).digest("hex").slice(0, 20);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
const now = Date.now();
|
|
1691
|
+
if (lookupKey && sessionCache.has(lookupKey)) {
|
|
1692
|
+
const entry = sessionCache.get(lookupKey);
|
|
1693
|
+
if (now - entry.lastSeen < SESSION_TTL_MS) {
|
|
1694
|
+
// Re-insert to update Map insertion order for O(1) LRU eviction
|
|
1695
|
+
sessionCache.delete(lookupKey);
|
|
1696
|
+
entry.lastSeen = now;
|
|
1697
|
+
sessionCache.set(lookupKey, entry);
|
|
1698
|
+
return entry.sessionId;
|
|
1699
|
+
}
|
|
1700
|
+
sessionCache.delete(lookupKey);
|
|
1701
|
+
}
|
|
1702
|
+
const newSessionId = identity.createSessionId(true);
|
|
1703
|
+
if (lookupKey) {
|
|
1704
|
+
if (sessionCache.size >= MAX_SESSIONS) {
|
|
1705
|
+
for (const [k, v] of sessionCache) {
|
|
1706
|
+
if (now - v.lastSeen >= SESSION_TTL_MS) sessionCache.delete(k);
|
|
1707
|
+
}
|
|
1708
|
+
if (sessionCache.size >= MAX_SESSIONS) {
|
|
1709
|
+
const lruKey = sessionCache.keys().next().value;
|
|
1710
|
+
if (lruKey !== undefined) sessionCache.delete(lruKey);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
sessionCache.set(lookupKey, { sessionId: newSessionId, lastSeen: now });
|
|
1714
|
+
}
|
|
1715
|
+
return newSessionId;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* `/api/proxy` — read and change the egress proxy; `/api/proxy/test` — prove a
|
|
1720
|
+
* candidate reaches upstream before it is trusted.
|
|
1721
|
+
*
|
|
1722
|
+
* The panel is the only caller, so the contract stays small: JSON in, JSON out,
|
|
1723
|
+
* one error field. Credentials are accepted on write and never echoed on read.
|
|
1724
|
+
*/
|
|
1725
|
+
async function handleProxyApi(req, res, pathname) {
|
|
1726
|
+
const send = (status, body) => {
|
|
1727
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
1728
|
+
res.end(JSON.stringify(body));
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
if (pathname === "/api/proxy" && (req.method === "GET" || req.method === "HEAD")) {
|
|
1732
|
+
return send(200, proxy.describe());
|
|
1733
|
+
}
|
|
1734
|
+
if (req.method !== "POST") {
|
|
1735
|
+
res.setHeader("Allow", "GET, POST");
|
|
1736
|
+
return send(405, { error: "Gunakan GET untuk status atau POST untuk mengubah." });
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
let payload = {};
|
|
1740
|
+
try {
|
|
1741
|
+
const text = await readRequestBody(req, 128 * 1024);
|
|
1742
|
+
payload = text ? JSON.parse(text) : {};
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
return send(err.statusCode === 413 ? 413 : 400, { error: `Body tidak valid: ${err.message}` });
|
|
1745
|
+
}
|
|
1746
|
+
if (!payload || typeof payload !== "object") {
|
|
1747
|
+
return send(400, { error: "Body harus berupa objek JSON." });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Pool management. `entries` replaces the whole pool; `pool: []` clears it.
|
|
1751
|
+
// Kept apart from the single-proxy `url` field so one request cannot mean two
|
|
1752
|
+
// different things, and so an operator can keep a single proxy configured as a
|
|
1753
|
+
// fallback while experimenting with a pool.
|
|
1754
|
+
const hasPool = Object.prototype.hasOwnProperty.call(payload, "entries") ||
|
|
1755
|
+
Object.prototype.hasOwnProperty.call(payload, "pool");
|
|
1756
|
+
if (hasPool && pathname === "/api/proxy") {
|
|
1757
|
+
const spec = Object.prototype.hasOwnProperty.call(payload, "entries")
|
|
1758
|
+
? { entries: payload.entries, strategy: payload.strategy, cooldownMs: payload.cooldownMs, maxCooldownMs: payload.maxCooldownMs }
|
|
1759
|
+
: payload.pool;
|
|
1760
|
+
const result = proxy.setPool(spec);
|
|
1761
|
+
if (!result.ok) return send(400, { error: result.error });
|
|
1762
|
+
console.log(
|
|
1763
|
+
`[opencode-free-bridge] Egress pool: ${result.pool.enabled}/${result.pool.size} aktif, strategi ${result.pool.strategy}`
|
|
1764
|
+
);
|
|
1765
|
+
return send(200, result);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
if (pathname === "/api/proxy/test" && Array.isArray(payload.entries)) {
|
|
1769
|
+
const { entries } = payload;
|
|
1770
|
+
if (entries.length === 0) return send(400, { error: "Daftar entries kosong." });
|
|
1771
|
+
// Probe without installing: the caller is evaluating candidates.
|
|
1772
|
+
const results = [];
|
|
1773
|
+
for (const [index, entry] of entries.entries()) {
|
|
1774
|
+
const parsed = proxy.parsePoolEntry(entry, index);
|
|
1775
|
+
if (!parsed.ok) {
|
|
1776
|
+
results.push({ id: `#${index + 1}`, host: null, ok: false, error: parsed.error });
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const verdict = await proxy.testProxy(parsed.entry.url, { probeUrl: resolveEndpoints().modelsUrl });
|
|
1780
|
+
results.push({
|
|
1781
|
+
id: parsed.entry.id,
|
|
1782
|
+
label: parsed.entry.label,
|
|
1783
|
+
host: proxy.displayHost(parsed.entry.url),
|
|
1784
|
+
ok: verdict.ok,
|
|
1785
|
+
apiStatus: verdict.apiStatus || null,
|
|
1786
|
+
exitIp: verdict.exitIp || null,
|
|
1787
|
+
latencyMs: verdict.latencyMs || null,
|
|
1788
|
+
error: verdict.error || null,
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
const anyOk = results.some((r) => r.ok);
|
|
1792
|
+
return send(anyOk ? 200 : 502, { ok: anyOk, entries: results });
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if (pathname === "/api/proxy/test" && typeof payload.url === "string") {
|
|
1796
|
+
const result = await proxy.testProxy(payload.url, { probeUrl: resolveEndpoints().modelsUrl });
|
|
1797
|
+
return send(result.ok ? 200 : 502, result);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
if (typeof payload.url === "string") {
|
|
1801
|
+
const result = proxy.setProxyUrl(payload.url);
|
|
1802
|
+
if (!result.ok) return send(400, { error: result.error });
|
|
1803
|
+
console.log(
|
|
1804
|
+
`[opencode-free-bridge] Egress ${result.mode === "proxy" ? `via proxy ${result.proxy}` : "direct"}`
|
|
1805
|
+
);
|
|
1806
|
+
return send(200, result);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
return send(400, { error: "Body harus berisi 'url' (satu proxy) atau 'entries' (pool)." });
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function readRequestBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
1813
|
+
// Early reject: if the client declares a huge body, refuse without reading
|
|
1814
|
+
// a single byte instead of buffering up to the limit first.
|
|
1815
|
+
const declared = parseInt(req?.headers?.["content-length"] || "", 10);
|
|
1816
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
1817
|
+
const err = new Error(`Request body exceeds limit of ${maxBytes} bytes`);
|
|
1818
|
+
err.statusCode = 413;
|
|
1819
|
+
err.code = "payload_too_large";
|
|
1820
|
+
return Promise.reject(err);
|
|
1821
|
+
}
|
|
1822
|
+
return new Promise((resolve, reject) => {
|
|
1823
|
+
const chunks = [];
|
|
1824
|
+
let totalBytes = 0;
|
|
1825
|
+
let done = false;
|
|
1826
|
+
const cleanup = () => {
|
|
1827
|
+
if (typeof req.off === "function") {
|
|
1828
|
+
req.off("error", onError);
|
|
1829
|
+
req.off("data", onData);
|
|
1830
|
+
req.off("end", onEnd);
|
|
1831
|
+
} else if (typeof req.removeListener === "function") {
|
|
1832
|
+
req.removeListener("error", onError);
|
|
1833
|
+
req.removeListener("data", onData);
|
|
1834
|
+
req.removeListener("end", onEnd);
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
|
|
1838
|
+
const onError = (err) => {
|
|
1839
|
+
if (done) return;
|
|
1840
|
+
done = true;
|
|
1841
|
+
cleanup();
|
|
1842
|
+
reject(err);
|
|
1843
|
+
};
|
|
1844
|
+
|
|
1845
|
+
const onData = (chunk) => {
|
|
1846
|
+
if (done) return;
|
|
1847
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1848
|
+
totalBytes += buf.length;
|
|
1849
|
+
if (totalBytes > maxBytes) {
|
|
1850
|
+
done = true;
|
|
1851
|
+
chunks.length = 0;
|
|
1852
|
+
cleanup();
|
|
1853
|
+
if (typeof req.pause === "function") req.pause();
|
|
1854
|
+
const err = new Error(`Request body exceeds limit of ${maxBytes} bytes`);
|
|
1855
|
+
err.statusCode = 413;
|
|
1856
|
+
err.code = "payload_too_large";
|
|
1857
|
+
reject(err);
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
chunks.push(buf);
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
const onEnd = () => {
|
|
1864
|
+
if (done) return;
|
|
1865
|
+
done = true;
|
|
1866
|
+
cleanup();
|
|
1867
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
req.on("error", onError);
|
|
1871
|
+
req.on("data", onData);
|
|
1872
|
+
req.on("end", onEnd);
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// --- HTTP Server ---
|
|
1877
|
+
function handleRequest(req, res) {
|
|
1878
|
+
req.on("error", (err) => {
|
|
1879
|
+
console.warn("[opencode-free-bridge] request stream error:", err.message);
|
|
1880
|
+
});
|
|
1881
|
+
res.on("error", (err) => {
|
|
1882
|
+
console.warn("[opencode-free-bridge] response stream error:", err.message);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
const reqOrigin = req.headers?.origin || "";
|
|
1886
|
+
const configuredOrigin = process.env.CORS_ORIGIN !== undefined ? process.env.CORS_ORIGIN : CORS_ORIGIN;
|
|
1887
|
+
if (configuredOrigin === "*") {
|
|
1888
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1889
|
+
} else if (configuredOrigin) {
|
|
1890
|
+
const allowed = configuredOrigin.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1891
|
+
if (allowed.includes(reqOrigin)) {
|
|
1892
|
+
res.setHeader("Access-Control-Allow-Origin", reqOrigin);
|
|
1893
|
+
res.setHeader("Vary", "Origin");
|
|
1894
|
+
}
|
|
1895
|
+
} else if (isLocalOrigin(reqOrigin)) {
|
|
1896
|
+
res.setHeader("Access-Control-Allow-Origin", reqOrigin);
|
|
1897
|
+
res.setHeader("Vary", "Origin");
|
|
1898
|
+
}
|
|
1899
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1900
|
+
res.setHeader(
|
|
1901
|
+
"Access-Control-Allow-Headers",
|
|
1902
|
+
"Content-Type, Authorization, X-Requested-With, X-Session-Id, Session-Id, X-Conversation-Id, X-9r-Session-Id, X-OpenCode-Session, X-OpenCode-Session-Id, X-Session-Affinity, X-OpenCode-Client, X-OpenCode-Project"
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
if (req.method === "OPTIONS") {
|
|
1906
|
+
res.writeHead(204);
|
|
1907
|
+
return res.end();
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const rawUrl = req.url || "/";
|
|
1911
|
+
const qIdx = rawUrl.indexOf("?");
|
|
1912
|
+
const rawPath = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
|
|
1913
|
+
const pathname = rawPath.length > 1 && rawPath.endsWith("/") ? rawPath.replace(/\/+$/, "") : rawPath;
|
|
1914
|
+
|
|
1915
|
+
// Panel: the human-facing page. JSON consumers keep using /health, which is
|
|
1916
|
+
// why "/" is no longer an alias for the health document.
|
|
1917
|
+
if ((req.method === "GET" || req.method === "HEAD") && (pathname === "" || pathname === "/")) {
|
|
1918
|
+
const denied = adminRequestError(req);
|
|
1919
|
+
if (denied) {
|
|
1920
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
|
|
1921
|
+
return res.end(req.method === "HEAD" ? undefined : denied);
|
|
1922
|
+
}
|
|
1923
|
+
const html = panel.renderPanel(proxy.describe(), { version: VERSION, port: PORT });
|
|
1924
|
+
res.writeHead(200, {
|
|
1925
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1926
|
+
"Cache-Control": "no-store",
|
|
1927
|
+
"Content-Length": Buffer.byteLength(html),
|
|
1928
|
+
"X-Frame-Options": "DENY",
|
|
1929
|
+
});
|
|
1930
|
+
return res.end(req.method === "HEAD" ? undefined : html);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
if (pathname === "/api/proxy" || pathname === "/api/proxy/test") {
|
|
1934
|
+
const denied = adminRequestError(req);
|
|
1935
|
+
if (denied) {
|
|
1936
|
+
res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
1937
|
+
return res.end(JSON.stringify({ error: denied }));
|
|
1938
|
+
}
|
|
1939
|
+
// Only JSON can change the setting. `text/plain` is a CORS "simple
|
|
1940
|
+
// request", so accepting it would let any page POST here without a
|
|
1941
|
+
// preflight; refusing it is the structural half of the CSRF defence.
|
|
1942
|
+
if (req.method === "POST") {
|
|
1943
|
+
const type = String(req.headers?.["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
1944
|
+
if (type !== "application/json") {
|
|
1945
|
+
res.writeHead(415, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
1946
|
+
return res.end(JSON.stringify({ error: "Content-Type harus application/json." }));
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
// `handleProxyApi` is async; an unexpected throw must become a 500 instead
|
|
1950
|
+
// of an unhandled rejection that leaves the socket hanging.
|
|
1951
|
+
handleProxyApi(req, res, pathname).catch((err) => {
|
|
1952
|
+
if (res.headersSent) return res.destroy();
|
|
1953
|
+
res.writeHead(500, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
1954
|
+
res.end(JSON.stringify({ error: `Kesalahan internal: ${err.message}` }));
|
|
1955
|
+
});
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// Health check
|
|
1960
|
+
if (pathname === "/health") {
|
|
1961
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1962
|
+
const idState = identity.getState();
|
|
1963
|
+
const proxyState = proxy.describe();
|
|
1964
|
+
const gateThrottled =
|
|
1965
|
+
lastFailedGateRetryTime > 0 &&
|
|
1966
|
+
Date.now() - lastFailedGateRetryTime < GATE_RETRY_COOLDOWN_MS &&
|
|
1967
|
+
lastFailedGateRetryEpoch === (idState.epoch || 0);
|
|
1968
|
+
return res.end(
|
|
1969
|
+
JSON.stringify({
|
|
1970
|
+
status: "ok",
|
|
1971
|
+
provider: "opencode-free-bridge",
|
|
1972
|
+
version: VERSION,
|
|
1973
|
+
runtime: typeof Bun !== "undefined" ? `bun/${Bun.version}` : `node/${process.versions.node}`,
|
|
1974
|
+
discoveredModels: activeModels.length,
|
|
1975
|
+
// Egress in force. `mode` is what actually carries the request: the
|
|
1976
|
+
// pool when one is configured, otherwise a single proxy, otherwise the
|
|
1977
|
+
// relay, otherwise direct. `proxy.mode` only reports the configured
|
|
1978
|
+
// shape, so a relay-only deployment still reads as `relay` here.
|
|
1979
|
+
// Credentials are never included; every host in `proxy` is host:port.
|
|
1980
|
+
egress: {
|
|
1981
|
+
mode:
|
|
1982
|
+
proxyState.mode === "pool"
|
|
1983
|
+
? "pool"
|
|
1984
|
+
: proxyState.mode === "proxy"
|
|
1985
|
+
? "proxy"
|
|
1986
|
+
: UPSTREAM_RELAY_URL
|
|
1987
|
+
? "relay"
|
|
1988
|
+
: "direct",
|
|
1989
|
+
...(UPSTREAM_RELAY_URL ? { relay: relayLabel(UPSTREAM_RELAY_URL) } : {}),
|
|
1990
|
+
proxy: proxyState,
|
|
1991
|
+
},
|
|
1992
|
+
identity: {
|
|
1993
|
+
userAgent: idState.userAgent,
|
|
1994
|
+
updateMode: idState.updateMode,
|
|
1995
|
+
sourceTag: idState.lastSourceTag,
|
|
1996
|
+
versionFloor: idState.versionFloor,
|
|
1997
|
+
lastCheckAgeMs: idState.lastCheckTime ? Date.now() - idState.lastCheckTime : null,
|
|
1998
|
+
ttlMs: idState.ttlMs,
|
|
1999
|
+
lastError: idState.lastRefreshError,
|
|
2000
|
+
release: idState.release,
|
|
2001
|
+
// Set in reactive mode when a release exists that has not been adopted
|
|
2002
|
+
// because the gate has not moved. `null` once adopted.
|
|
2003
|
+
available: idState.available,
|
|
2004
|
+
// The header names in force, and any the recipe asks for that this
|
|
2005
|
+
// bridge cannot fill — a non-empty `unsentHeaders` means a manual
|
|
2006
|
+
// mapping is needed before the next gate move is followed.
|
|
2007
|
+
headers: idState.headers,
|
|
2008
|
+
unsentHeaders: idState.unsentHeaders,
|
|
2009
|
+
// The values derived from upstream sources: the wire endpoints (from
|
|
2010
|
+
// the provider catalog) and the tool allowlist (from the release's
|
|
2011
|
+
// tool plugin sources). Visible because they are the layers that
|
|
2012
|
+
// change without a bridge release.
|
|
2013
|
+
endpoints: idState.endpoints,
|
|
2014
|
+
toolNames: idState.toolNames,
|
|
2015
|
+
tools: idState.tools,
|
|
2016
|
+
gate: {
|
|
2017
|
+
status: lastIpAbuseAt > 0 ? "ip-blocked" : gateThrottled ? "throttled" : "open",
|
|
2018
|
+
// The gateway keys an abuse block to the egress network, so there is
|
|
2019
|
+
// no known expiry to advertise. `ipBlockedSince` is the last time
|
|
2020
|
+
// upstream returned that refusal.
|
|
2021
|
+
ipBlockedSince: lastIpAbuseAt || null,
|
|
2022
|
+
throttledUntil: gateThrottled
|
|
2023
|
+
? lastFailedGateRetryTime + GATE_RETRY_COOLDOWN_MS
|
|
2024
|
+
: null,
|
|
2025
|
+
cooldownMs: GATE_RETRY_COOLDOWN_MS,
|
|
2026
|
+
// The shape anchors the gate checks. Reported so a deployment can
|
|
2027
|
+
// confirm it is sending them without waiting for a 403.
|
|
2028
|
+
anchors: {
|
|
2029
|
+
tools: identity.GATE_ANCHOR_TOOL_NAMES.slice(),
|
|
2030
|
+
headers: identity.GATE_ANCHOR_HEADER_NAMES.slice(),
|
|
2031
|
+
},
|
|
2032
|
+
// The most recent gate rejection: what was sent, what was missing,
|
|
2033
|
+
// and upstream's own correlation id. `shapeOk: false` means the
|
|
2034
|
+
// bridge produced a request the gate refuses — not an IP problem.
|
|
2035
|
+
lastRejection: lastGateDiagnostic,
|
|
2036
|
+
lastUpstreamError,
|
|
2037
|
+
},
|
|
2038
|
+
},
|
|
2039
|
+
})
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Model list (triggers async re-discovery when the hourly TTL expired)
|
|
2044
|
+
if (req.method === "GET" && (pathname === "/v1/models" || pathname === "/models")) {
|
|
2045
|
+
if (activeModels.length === 0) {
|
|
2046
|
+
discoverModels()
|
|
2047
|
+
.catch(() => {})
|
|
2048
|
+
.finally(() => {
|
|
2049
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2050
|
+
res.end(cachedModelsPayload);
|
|
2051
|
+
});
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (Date.now() - lastDiscoveryTime > DISCOVERY_TTL_MS) {
|
|
2055
|
+
discoverModels().catch(() => {});
|
|
2056
|
+
}
|
|
2057
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2058
|
+
return res.end(cachedModelsPayload);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Chat completions
|
|
2062
|
+
if (req.method === "POST" && (pathname === "/v1/chat/completions" || pathname === "/chat/completions")) {
|
|
2063
|
+
(async () => {
|
|
2064
|
+
let bodyText;
|
|
2065
|
+
try {
|
|
2066
|
+
bodyText = await readRequestBody(req);
|
|
2067
|
+
} catch (err) {
|
|
2068
|
+
if (err.statusCode === 413) {
|
|
2069
|
+
res.writeHead(413, { "Content-Type": "application/json", "Connection": "close" });
|
|
2070
|
+
res.end(
|
|
2071
|
+
JSON.stringify({
|
|
2072
|
+
error: {
|
|
2073
|
+
message: err.message,
|
|
2074
|
+
type: "invalid_request_error",
|
|
2075
|
+
code: "payload_too_large",
|
|
2076
|
+
},
|
|
2077
|
+
})
|
|
2078
|
+
);
|
|
2079
|
+
res.once("finish", () => {
|
|
2080
|
+
if (typeof req.destroy === "function") req.destroy();
|
|
2081
|
+
});
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
2085
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2086
|
+
res.end(
|
|
2087
|
+
JSON.stringify({
|
|
2088
|
+
error: { message: "Failed to read request body", type: "invalid_request_error" },
|
|
2089
|
+
})
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
let body;
|
|
2096
|
+
try {
|
|
2097
|
+
body = JSON.parse(bodyText);
|
|
2098
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
2099
|
+
throw new Error("Request body must be a JSON object");
|
|
2100
|
+
}
|
|
2101
|
+
} catch (err) {
|
|
2102
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2103
|
+
return res.end(
|
|
2104
|
+
JSON.stringify({
|
|
2105
|
+
error: {
|
|
2106
|
+
message: `Invalid JSON body: ${err.message}`,
|
|
2107
|
+
type: "invalid_request_error",
|
|
2108
|
+
code: "invalid_json",
|
|
2109
|
+
},
|
|
2110
|
+
})
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
const targetModel = normalizeModel(body.model);
|
|
2115
|
+
if (!targetModel) {
|
|
2116
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2117
|
+
return res.end(
|
|
2118
|
+
JSON.stringify({
|
|
2119
|
+
error: {
|
|
2120
|
+
message: "Missing or empty required parameter: 'model'",
|
|
2121
|
+
type: "invalid_request_error",
|
|
2122
|
+
param: "model",
|
|
2123
|
+
code: "model_required",
|
|
2124
|
+
},
|
|
2125
|
+
})
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// Free tier only: this bridge sends `Authorization: Bearer public` and
|
|
2130
|
+
// cannot authenticate anything else, so a model the catalog prices above
|
|
2131
|
+
// zero can only fail upstream. Say that here — an upstream 401 ("no
|
|
2132
|
+
// session") sends the user hunting for a login that does not exist.
|
|
2133
|
+
const modelVerdict = identity.getCatalogVerdict(targetModel);
|
|
2134
|
+
if (modelVerdict && modelVerdict !== "free") {
|
|
2135
|
+
const isRetired = modelVerdict === "deprecated";
|
|
2136
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2137
|
+
return res.end(
|
|
2138
|
+
JSON.stringify({
|
|
2139
|
+
error: {
|
|
2140
|
+
message: isRetired
|
|
2141
|
+
? `Model '${targetModel}' has been retired upstream. Free models are listed at /v1/models.`
|
|
2142
|
+
: `Model '${targetModel}' is not on the free tier, and this bridge has no account to run it with. Free models are listed at /v1/models.`,
|
|
2143
|
+
type: "invalid_request_error",
|
|
2144
|
+
param: "model",
|
|
2145
|
+
code: isRetired ? "model_deprecated" : "model_not_free",
|
|
2146
|
+
},
|
|
2147
|
+
})
|
|
2148
|
+
);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (!Array.isArray(body.messages)) {
|
|
2152
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2153
|
+
return res.end(
|
|
2154
|
+
JSON.stringify({
|
|
2155
|
+
error: {
|
|
2156
|
+
message: "Missing or invalid required parameter: 'messages' must be an array",
|
|
2157
|
+
type: "invalid_request_error",
|
|
2158
|
+
param: "messages",
|
|
2159
|
+
code: "messages_required",
|
|
2160
|
+
},
|
|
2161
|
+
})
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const clientStream = Boolean(body.stream);
|
|
2166
|
+
|
|
2167
|
+
// Lazy identity check: no timer runs in the background. The first request
|
|
2168
|
+
// after the TTL kicks off a refresh in the background and proceeds with
|
|
2169
|
+
// the current mask; the next request uses the refreshed one.
|
|
2170
|
+
identity.ensureFresh();
|
|
2171
|
+
syncUserAgent();
|
|
2172
|
+
|
|
2173
|
+
let useResponses = isResponsesModel(targetModel);
|
|
2174
|
+
const sessionId = getPersistentSessionId(req, body);
|
|
2175
|
+
let retryHeaders = null;
|
|
2176
|
+
const upstreamHeaders = buildUpstreamHeaders(req, sessionId);
|
|
2177
|
+
|
|
2178
|
+
const abortController = new AbortController();
|
|
2179
|
+
let timedOut = false;
|
|
2180
|
+
let clientDisconnected = false;
|
|
2181
|
+
const timeoutId = setTimeout(() => {
|
|
2182
|
+
timedOut = true;
|
|
2183
|
+
abortController.abort(new Error("Upstream request timed out"));
|
|
2184
|
+
}, UPSTREAM_TIMEOUT_MS);
|
|
2185
|
+
|
|
2186
|
+
const onClose = () => {
|
|
2187
|
+
if (!res.writableEnded) {
|
|
2188
|
+
clientDisconnected = true;
|
|
2189
|
+
abortController.abort(new Error("Client disconnected"));
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
res.on("close", onClose);
|
|
2193
|
+
|
|
2194
|
+
let reader = null;
|
|
2195
|
+
try {
|
|
2196
|
+
let { upstreamBody, aliasToOriginal } = buildUpstreamPayload(body, targetModel, useResponses);
|
|
2197
|
+
let liveEndpoints = resolveEndpoints(targetModel);
|
|
2198
|
+
let upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
|
|
2199
|
+
|
|
2200
|
+
// One egress per client request. Everything below — the protocol-lane
|
|
2201
|
+
// retry, the endpoint-migration retry, the 5xx retry, and the gate retry
|
|
2202
|
+
// — reuses it, because upstream ties a retry to the address that saw the
|
|
2203
|
+
// first attempt. Only two things move it: an unreachable proxy (the
|
|
2204
|
+
// request never left) and a gate rejection that names this egress as the
|
|
2205
|
+
// problem. Both are handled where they happen and update this state.
|
|
2206
|
+
const egressState = { egress: proxy.pick(), egressUrl: null, transportFailures: 0 };
|
|
2207
|
+
egressState.egressUrl = egressState.egress.url;
|
|
2208
|
+
const egressUrl = () => egressState.egressUrl;
|
|
2209
|
+
|
|
2210
|
+
let upstreamRes = await egressFetch(upstreamUrl, {
|
|
2211
|
+
method: "POST",
|
|
2212
|
+
headers: upstreamHeaders,
|
|
2213
|
+
body: JSON.stringify(upstreamBody),
|
|
2214
|
+
signal: abortController.signal,
|
|
2215
|
+
}, egressState);
|
|
2216
|
+
|
|
2217
|
+
// If a newly released model requires the opposite protocol lane, learn it
|
|
2218
|
+
// and transparently retry once on the matching lane.
|
|
2219
|
+
if (!upstreamRes.ok && upstreamRes.status === 400) {
|
|
2220
|
+
const errText = await upstreamRes.text();
|
|
2221
|
+
let errJson;
|
|
2222
|
+
try {
|
|
2223
|
+
errJson = JSON.parse(errText);
|
|
2224
|
+
} catch {
|
|
2225
|
+
errJson = { message: errText };
|
|
2226
|
+
}
|
|
2227
|
+
const errType = errJson.error?.type || "";
|
|
2228
|
+
const errMsg = errJson.error?.message || errJson.message || "";
|
|
2229
|
+
if (
|
|
2230
|
+
errType === "ModelProtocolUnsupported" ||
|
|
2231
|
+
errMsg.includes("ModelProtocolUnsupported") ||
|
|
2232
|
+
errMsg.includes("does not support this protocol")
|
|
2233
|
+
) {
|
|
2234
|
+
useResponses = !useResponses;
|
|
2235
|
+
markProtocolSupport(targetModel, useResponses);
|
|
2236
|
+
({ upstreamBody, aliasToOriginal } = buildUpstreamPayload(body, targetModel, useResponses));
|
|
2237
|
+
upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
|
|
2238
|
+
upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
2239
|
+
method: "POST",
|
|
2240
|
+
headers: upstreamHeaders,
|
|
2241
|
+
body: JSON.stringify(upstreamBody),
|
|
2242
|
+
signal: abortController.signal,
|
|
2243
|
+
}, egressUrl());
|
|
2244
|
+
} else {
|
|
2245
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2246
|
+
return res.end(
|
|
2247
|
+
JSON.stringify({
|
|
2248
|
+
error: {
|
|
2249
|
+
message: errMsg || "Upstream error 400",
|
|
2250
|
+
type: errType || "upstream_error",
|
|
2251
|
+
code: 400,
|
|
2252
|
+
},
|
|
2253
|
+
})
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// Endpoint migration. The provider catalog is the authority for the wire
|
|
2259
|
+
// base URL, so an upstream path move (the /zen/v1 → /inference/v1 move)
|
|
2260
|
+
// shows up as a 404/410 at the old path — not as a gate rejection. A 404
|
|
2261
|
+
// is otherwise indistinguishable from "this route does not exist", so
|
|
2262
|
+
// re-derive from the catalog and retry once, but only when the derived
|
|
2263
|
+
// URL actually differs. Replaying the same URL on a 404 would loop.
|
|
2264
|
+
// A body can only be read once. When an earlier branch has already
|
|
2265
|
+
// consumed it, the generic error path below must reuse the text instead
|
|
2266
|
+
// of reading an empty stream and reporting "Upstream error <status>".
|
|
2267
|
+
let consumedErrorText = null;
|
|
2268
|
+
if (!upstreamRes.ok && (upstreamRes.status === 404 || upstreamRes.status === 410)) {
|
|
2269
|
+
const staleUrl = upstreamUrl;
|
|
2270
|
+
let moveText = "";
|
|
2271
|
+
try {
|
|
2272
|
+
moveText = await upstreamRes.text();
|
|
2273
|
+
} catch {}
|
|
2274
|
+
consumedErrorText = moveText;
|
|
2275
|
+
// A 410 can also be about the *model*, not the route
|
|
2276
|
+
// (`{"error":{"type":"ModelDeprecated"}}`). Retrying such a request at
|
|
2277
|
+
// another URL would just repeat the same model-level answer, so the
|
|
2278
|
+
// endpoint heal only applies when the error does not name a model.
|
|
2279
|
+
let moveErrType = "";
|
|
2280
|
+
try {
|
|
2281
|
+
const j = JSON.parse(moveText);
|
|
2282
|
+
moveErrType = j?.error?.type || j?.type || "";
|
|
2283
|
+
} catch {}
|
|
2284
|
+
const modelLevelError = /model(deprecated|unavailable|protocolunsupported)/i.test(
|
|
2285
|
+
moveErrType
|
|
2286
|
+
);
|
|
2287
|
+
if (!modelLevelError) {
|
|
2288
|
+
await identity.refresh({ force: true }).catch(() => null);
|
|
2289
|
+
liveEndpoints = resolveEndpoints(targetModel);
|
|
2290
|
+
const derivedUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
|
|
2291
|
+
if (derivedUrl && derivedUrl !== staleUrl) {
|
|
2292
|
+
console.warn(
|
|
2293
|
+
`[opencode-free-bridge] Upstream endpoint moved (${upstreamRes.status}): ${staleUrl} → ${derivedUrl}; retrying once`
|
|
2294
|
+
);
|
|
2295
|
+
upstreamUrl = derivedUrl;
|
|
2296
|
+
upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
2297
|
+
method: "POST",
|
|
2298
|
+
headers: upstreamHeaders,
|
|
2299
|
+
body: JSON.stringify(upstreamBody),
|
|
2300
|
+
signal: abortController.signal,
|
|
2301
|
+
}, egressUrl());
|
|
2302
|
+
// The text above belonged to the response we just replaced. Reading
|
|
2303
|
+
// it for the new response would report the old error verbatim.
|
|
2304
|
+
consumedErrorText = null;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Spent allowance on a pooled egress. A 429 means transport and request
|
|
2310
|
+
// shape were both accepted and only this IP's quota is gone, so the one
|
|
2311
|
+
// useful action is to spend the retry on a different IP instead of
|
|
2312
|
+
// replaying an identical request at an address upstream just refused.
|
|
2313
|
+
// The cooldown is recorded either way, so the next request also moves.
|
|
2314
|
+
if (!upstreamRes.ok && upstreamRes.status === 429) {
|
|
2315
|
+
const poolState = proxy.describe().pool;
|
|
2316
|
+
const canFailover = poolState && poolState.active && poolState.enabled > 1;
|
|
2317
|
+
recordEgressFailure(egressState, {
|
|
2318
|
+
reason: "allowance 429",
|
|
2319
|
+
cooldownMs: poolState ? poolState.cooldownMs * 4 : null,
|
|
2320
|
+
});
|
|
2321
|
+
if (canFailover) {
|
|
2322
|
+
const next = proxy.pick({ exclude: egressState.egressUrl });
|
|
2323
|
+
if (next.url !== egressState.egressUrl) {
|
|
2324
|
+
console.warn(
|
|
2325
|
+
`[opencode-free-bridge] Egress ${
|
|
2326
|
+
proxy.displayHost(egressState.egressUrl) || "direct"
|
|
2327
|
+
} is out of allowance (429); retrying through ${
|
|
2328
|
+
proxy.displayHost(next.url) || "direct"
|
|
2329
|
+
}`
|
|
2330
|
+
);
|
|
2331
|
+
egressState.egress = next;
|
|
2332
|
+
egressState.egressUrl = next.url;
|
|
2333
|
+
upstreamRes = await egressFetch(upstreamUrl, {
|
|
2334
|
+
method: "POST",
|
|
2335
|
+
headers: upstreamHeaders,
|
|
2336
|
+
body: JSON.stringify(upstreamBody),
|
|
2337
|
+
signal: abortController.signal,
|
|
2338
|
+
}, egressState);
|
|
2339
|
+
consumedErrorText = null;
|
|
2340
|
+
// The retry may have landed on a second spent IP. Record it too,
|
|
2341
|
+
// or the next request would pick it again and learn the same thing.
|
|
2342
|
+
if (upstreamRes.status === 429) {
|
|
2343
|
+
recordEgressFailure(egressState, {
|
|
2344
|
+
reason: "allowance 429",
|
|
2345
|
+
cooldownMs: poolState.cooldownMs * 4,
|
|
2346
|
+
});
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Transient 5xx self-heal: upstream Workers intermittently drop empty-body
|
|
2353
|
+
// HTTP 500/502/503/504 responses (e.g. cf-placement: remote-IAD) during
|
|
2354
|
+
// free-inference degradation. Retry once with fresh trace/session headers.
|
|
2355
|
+
if (!upstreamRes.ok && upstreamRes.status >= 500 && upstreamRes.status < 600) {
|
|
2356
|
+
const first5xx = upstreamRes.status;
|
|
2357
|
+
const firstLogId =
|
|
2358
|
+
typeof upstreamRes.headers?.get === "function"
|
|
2359
|
+
? upstreamRes.headers.get("x-opencode-log-id")
|
|
2360
|
+
: null;
|
|
2361
|
+
try {
|
|
2362
|
+
await upstreamRes.text();
|
|
2363
|
+
} catch {}
|
|
2364
|
+
const retry5xxSid = regenerateSessionId(req, body, sessionId);
|
|
2365
|
+
const retry5xxHeaders = buildUpstreamHeaders(req, retry5xxSid);
|
|
2366
|
+
console.warn(
|
|
2367
|
+
`[opencode-free-bridge] Upstream returned ${first5xx}` +
|
|
2368
|
+
(firstLogId ? ` [x-opencode-log-id: ${firstLogId}]` : "") +
|
|
2369
|
+
`; retrying once with fresh session`
|
|
2370
|
+
);
|
|
2371
|
+
upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
2372
|
+
method: "POST",
|
|
2373
|
+
headers: retry5xxHeaders,
|
|
2374
|
+
body: JSON.stringify(upstreamBody),
|
|
2375
|
+
signal: abortController.signal,
|
|
2376
|
+
}, egressUrl());
|
|
2377
|
+
consumedErrorText = null;
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
// Self-healing identity. A gate rejection is identified by the error
|
|
2381
|
+
// *body*, not the status line: a 403 is only the gate when its type or
|
|
2382
|
+
// message says so, while 426 is the version-floor handshake by itself.
|
|
2383
|
+
// The body must therefore be read before the decision — passing empty
|
|
2384
|
+
// strings here made this branch unreachable for 403.
|
|
2385
|
+
if (!upstreamRes.ok) {
|
|
2386
|
+
let errText = consumedErrorText ?? "";
|
|
2387
|
+
if (consumedErrorText === null) {
|
|
2388
|
+
try {
|
|
2389
|
+
errText = await upstreamRes.text();
|
|
2390
|
+
} catch {
|
|
2391
|
+
errText = "";
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
let errJson = null;
|
|
2395
|
+
try {
|
|
2396
|
+
errJson = JSON.parse(errText);
|
|
2397
|
+
} catch {
|
|
2398
|
+
errJson = null;
|
|
2399
|
+
}
|
|
2400
|
+
const errType = errJson?.error?.type || errJson?.type || "";
|
|
2401
|
+
const rawMsg =
|
|
2402
|
+
errJson?.error?.message ||
|
|
2403
|
+
errJson?.message ||
|
|
2404
|
+
errText ||
|
|
2405
|
+
`Upstream error ${upstreamRes.status}`;
|
|
2406
|
+
|
|
2407
|
+
// Terminal branch. Upstream names an IP-level abuse block in the
|
|
2408
|
+
// message, and that is a different failure from the shape gate: the
|
|
2409
|
+
// gateway has flagged this network, so refreshing the recipe, minting
|
|
2410
|
+
// a new session id, or replaying the request cannot change the
|
|
2411
|
+
// answer. Measured against the gateway's own reports: the official
|
|
2412
|
+
// CLI gets the same refusal from the same IP. Return it verbatim and
|
|
2413
|
+
// leave the identity layer untouched.
|
|
2414
|
+
if (identity.isIpAbuseRejection(upstreamRes.status, rawMsg)) {
|
|
2415
|
+
// The block is keyed to this network's IP, so this egress is the
|
|
2416
|
+
// problem: park it so the next request leaves through another entry.
|
|
2417
|
+
recordEgressFailure(egressState, { reason: "ip-abuse block" });
|
|
2418
|
+
return respondIpAbuse(
|
|
2419
|
+
res,
|
|
2420
|
+
upstreamRes.status,
|
|
2421
|
+
errType,
|
|
2422
|
+
rawMsg,
|
|
2423
|
+
upstreamLogIdOf(upstreamRes),
|
|
2424
|
+
targetModel
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
if (!isFreeTierCliGateError(upstreamRes.status, errType, rawMsg)) {
|
|
2429
|
+
const upstreamLogId =
|
|
2430
|
+
typeof upstreamRes.headers?.get === "function"
|
|
2431
|
+
? upstreamRes.headers.get("x-opencode-log-id")
|
|
2432
|
+
: null;
|
|
2433
|
+
const cfRay =
|
|
2434
|
+
typeof upstreamRes.headers?.get === "function"
|
|
2435
|
+
? upstreamRes.headers.get("cf-ray")
|
|
2436
|
+
: null;
|
|
2437
|
+
const cfPlacement =
|
|
2438
|
+
typeof upstreamRes.headers?.get === "function"
|
|
2439
|
+
? upstreamRes.headers.get("cf-placement")
|
|
2440
|
+
: null;
|
|
2441
|
+
if (upstreamRes.status >= 500) {
|
|
2442
|
+
lastUpstreamError = {
|
|
2443
|
+
at: Date.now(),
|
|
2444
|
+
status: upstreamRes.status,
|
|
2445
|
+
logId: upstreamLogId,
|
|
2446
|
+
cfRay,
|
|
2447
|
+
cfPlacement,
|
|
2448
|
+
message: rawMsg,
|
|
2449
|
+
model: targetModel,
|
|
2450
|
+
};
|
|
2451
|
+
console.warn(
|
|
2452
|
+
`[opencode-free-bridge] Upstream server error (${upstreamRes.status}) on model ${targetModel}: ${rawMsg}` +
|
|
2453
|
+
(upstreamLogId ? ` [x-opencode-log-id: ${upstreamLogId}]` : "") +
|
|
2454
|
+
(cfRay ? ` [cf-ray: ${cfRay}]` : "") +
|
|
2455
|
+
(cfPlacement ? ` [cf-placement: ${cfPlacement}]` : "")
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
const errHeaders = { "Content-Type": "application/json" };
|
|
2459
|
+
if (upstreamLogId) errHeaders["X-OpenCode-Log-Id"] = upstreamLogId;
|
|
2460
|
+
if (upstreamRes.status >= 500) errHeaders["X-OpenCode-Gate"] = "upstream-error";
|
|
2461
|
+
res.writeHead(upstreamRes.status, errHeaders);
|
|
2462
|
+
return res.end(
|
|
2463
|
+
JSON.stringify({
|
|
2464
|
+
error: {
|
|
2465
|
+
message: rawMsg,
|
|
2466
|
+
type: errType || "upstream_error",
|
|
2467
|
+
code: upstreamRes.status,
|
|
2468
|
+
...(upstreamLogId ? { log_id: upstreamLogId } : {}),
|
|
2469
|
+
},
|
|
2470
|
+
})
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
const gateStatus = upstreamRes.status;
|
|
2475
|
+
const verdict = await identity
|
|
2476
|
+
.onGateRejection(gateStatus, errType, rawMsg, false)
|
|
2477
|
+
.catch(() => ({ retry: false, reason: "refresh-error" }));
|
|
2478
|
+
|
|
2479
|
+
// Always retry once on a gate rejection, even when the refresh learned
|
|
2480
|
+
// nothing new. Two inputs to the gate are *minted per request* rather
|
|
2481
|
+
// than read from the recipe — the session id and the tool set — so a
|
|
2482
|
+
// retry with fresh values is a genuinely different request. Measured
|
|
2483
|
+
// upstream behaviour: a session id this bridge did not mint (or one
|
|
2484
|
+
// minted by a rejected recipe) is refused with FreeTierError even
|
|
2485
|
+
// when every header matches, while the same request with a freshly
|
|
2486
|
+
// minted id is accepted.
|
|
2487
|
+
syncUserAgent();
|
|
2488
|
+
// Re-mint the session id. The one minted before this request was
|
|
2489
|
+
// produced by the recipe the gate just rejected; replaying it would
|
|
2490
|
+
// send the same rejected shape and fail identically. The cached
|
|
2491
|
+
// entry is dropped so the next request also gets a correct id.
|
|
2492
|
+
const retrySessionId = regenerateSessionId(req, body, sessionId);
|
|
2493
|
+
const currentEpoch = identity.getState().epoch || 0;
|
|
2494
|
+
const skipCooldownRetry =
|
|
2495
|
+
!verdict.retry &&
|
|
2496
|
+
lastFailedGateRetryTime > 0 &&
|
|
2497
|
+
Date.now() - lastFailedGateRetryTime < GATE_RETRY_COOLDOWN_MS &&
|
|
2498
|
+
lastFailedGateRetryEpoch === currentEpoch;
|
|
2499
|
+
|
|
2500
|
+
if (!skipCooldownRetry) {
|
|
2501
|
+
// A gate rejection on a pooled egress is the one case where the
|
|
2502
|
+
// bridge can act on it locally: the allowance is per IP, so a
|
|
2503
|
+
// different entry is a different allowance. Failed over before the
|
|
2504
|
+
// retry so the retry is a genuinely different request in both shape
|
|
2505
|
+
// and egress. The old entry is put in cooldown; with a single proxy
|
|
2506
|
+
// (or none) `pick()` hands back the same egress and this is a no-op.
|
|
2507
|
+
const pooled = proxy.describe().pool;
|
|
2508
|
+
const canFailover = pooled && pooled.active && pooled.enabled > 1;
|
|
2509
|
+
if (canFailover && egressState.egressUrl) {
|
|
2510
|
+
proxy.markFailure(egressState.egressUrl, {
|
|
2511
|
+
reason: `gate ${gateStatus}`,
|
|
2512
|
+
cooldownMs: gateStatus === 429 ? pooled.cooldownMs * 4 : null,
|
|
2513
|
+
});
|
|
2514
|
+
const next = proxy.pick({ exclude: egressState.egressUrl });
|
|
2515
|
+
if (next.url && next.url !== egressState.egressUrl) {
|
|
2516
|
+
console.warn(
|
|
2517
|
+
`[opencode-free-bridge] Gate refused egress ${
|
|
2518
|
+
proxy.displayHost(egressState.egressUrl) || "direct"
|
|
2519
|
+
} (${gateStatus}); moving to ${proxy.displayHost(next.url) || "direct"} for the retry`
|
|
2520
|
+
);
|
|
2521
|
+
egressState.egress = next;
|
|
2522
|
+
egressState.egressUrl = next.url;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
console.warn(
|
|
2526
|
+
`[opencode-free-bridge] Upstream CLI gate rejected request (${gateStatus}, ${verdict.reason || "unknown"}); refreshing identity and retrying once with full tool set`
|
|
2527
|
+
);
|
|
2528
|
+
({ upstreamBody, aliasToOriginal } = buildUpstreamPayload(
|
|
2529
|
+
body,
|
|
2530
|
+
targetModel,
|
|
2531
|
+
useResponses,
|
|
2532
|
+
"full"
|
|
2533
|
+
));
|
|
2534
|
+
// Re-resolve the endpoint *after* the refresh: a catalog change is one
|
|
2535
|
+
// of the things the refresh can learn, and the retry must go to the
|
|
2536
|
+
// new URL — reusing the pre-refresh value would defeat the derivation.
|
|
2537
|
+
liveEndpoints = resolveEndpoints(targetModel);
|
|
2538
|
+
upstreamUrl = useResponses ? liveEndpoints.responsesUrl : liveEndpoints.chatUrl;
|
|
2539
|
+
retryHeaders = buildUpstreamHeaders(req, retrySessionId);
|
|
2540
|
+
upstreamRes = await upstreamFetch(upstreamUrl, {
|
|
2541
|
+
method: "POST",
|
|
2542
|
+
headers: retryHeaders,
|
|
2543
|
+
body: JSON.stringify(upstreamBody),
|
|
2544
|
+
signal: abortController.signal,
|
|
2545
|
+
}, egressUrl());
|
|
2546
|
+
} else {
|
|
2547
|
+
console.warn(
|
|
2548
|
+
`[opencode-free-bridge] Upstream CLI gate rejected request (${gateStatus}); skipping retry because a full-shape retry already failed <${Math.round(GATE_RETRY_COOLDOWN_MS / 1000)}s ago on the current identity (IP cooldown active)`
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
if (!upstreamRes.ok) {
|
|
2553
|
+
// The retry already used the freshest derived identity (endpoint,
|
|
2554
|
+
// session id, full tool set) — so a repeated FreeTierError is not a
|
|
2555
|
+
// shape problem this bridge can learn its way out of. Measured
|
|
2556
|
+
// 403 FreeTierError has more than one cause, and the message alone
|
|
2557
|
+
// cannot distinguish them:
|
|
2558
|
+
//
|
|
2559
|
+
// 1. transport fingerprint — measured: Node and curl are refused
|
|
2560
|
+
// 100% while Bun succeeds 100% from the same IP in the same
|
|
2561
|
+
// minute. The CLI ships as a Bun binary, so only Bun matches.
|
|
2562
|
+
// 2. per-IP allowance — also returned as 403. Measured: after a
|
|
2563
|
+
// few dozen probes the real CLI v2.0.32 gets the identical 403
|
|
2564
|
+
// from the same IP that answered 200 an hour earlier, and so
|
|
2565
|
+
// does the bridge. The transport had not changed.
|
|
2566
|
+
//
|
|
2567
|
+
// A 429 FreeUsageLimitError is unambiguous: transport passed and
|
|
2568
|
+
// the quota is spent. Observed on a fresh IP while 403 was being
|
|
2569
|
+
// returned elsewhere, which is how the two were told apart.
|
|
2570
|
+
//
|
|
2571
|
+
// Only the runtime can be checked locally, so report that and name
|
|
2572
|
+
// both possibilities instead of asserting the wrong one.
|
|
2573
|
+
// Read the retry's own body before logging. The first attempt's
|
|
2574
|
+
// message describes a shape this bridge has already replaced, so
|
|
2575
|
+
// reporting it here would describe a request nobody sent.
|
|
2576
|
+
let retryText = errText;
|
|
2577
|
+
let retryJson = errJson;
|
|
2578
|
+
if (!skipCooldownRetry) {
|
|
2579
|
+
retryText = "";
|
|
2580
|
+
try {
|
|
2581
|
+
retryText = await upstreamRes.text();
|
|
2582
|
+
} catch {}
|
|
2583
|
+
retryJson = null;
|
|
2584
|
+
try {
|
|
2585
|
+
retryJson = JSON.parse(retryText);
|
|
2586
|
+
} catch {}
|
|
2587
|
+
}
|
|
2588
|
+
const retryType = retryJson?.error?.type || retryJson?.type || errType || "upstream_error";
|
|
2589
|
+
const retryMsg =
|
|
2590
|
+
retryJson?.error?.message || retryJson?.message || retryText || rawMsg;
|
|
2591
|
+
// Report the *retry* status, not the first attempt's: they differ
|
|
2592
|
+
// whenever upstream answers the refreshed shape differently (a 426
|
|
2593
|
+
// floor handshake followed by a 429 capacity answer is common), and
|
|
2594
|
+
// logging the old status misattributes the final verdict.
|
|
2595
|
+
const retryStatus = upstreamRes.status;
|
|
2596
|
+
// The retry can reveal the block even when the first attempt still
|
|
2597
|
+
// carried the shape message (upstream mixes the two while a flag is
|
|
2598
|
+
// being applied). That is still terminal: answer it verbatim and do
|
|
2599
|
+
// not run the shape audit or append an update hint.
|
|
2600
|
+
if (identity.isIpAbuseRejection(retryStatus, retryMsg)) {
|
|
2601
|
+
if (!skipCooldownRetry) {
|
|
2602
|
+
lastFailedGateRetryTime = Date.now();
|
|
2603
|
+
lastFailedGateRetryEpoch = identity.getState().epoch || 0;
|
|
2604
|
+
}
|
|
2605
|
+
recordEgressFailure(egressState, { reason: "ip-abuse block" });
|
|
2606
|
+
return respondIpAbuse(
|
|
2607
|
+
res,
|
|
2608
|
+
retryStatus,
|
|
2609
|
+
retryType,
|
|
2610
|
+
retryMsg,
|
|
2611
|
+
upstreamLogIdOf(upstreamRes),
|
|
2612
|
+
targetModel
|
|
2613
|
+
);
|
|
2614
|
+
}
|
|
2615
|
+
const retryFreeTierMsg = /freetiererror|free tier|free models|only available in opencode/i.test(retryMsg);
|
|
2616
|
+
const onNode = typeof Bun === "undefined";
|
|
2617
|
+
// A 429 means transport and shape both passed and only capacity is
|
|
2618
|
+
// exhausted. Calling that a gate rejection would send the user to
|
|
2619
|
+
// re-check headers that upstream already accepted.
|
|
2620
|
+
const capacity = retryStatus === 429;
|
|
2621
|
+
if (capacity) {
|
|
2622
|
+
lastFailedGateRetryTime = 0;
|
|
2623
|
+
} else if (!skipCooldownRetry && isFreeTierCliGateError(retryStatus, retryType, retryMsg)) {
|
|
2624
|
+
lastFailedGateRetryTime = Date.now();
|
|
2625
|
+
lastFailedGateRetryEpoch = identity.getState().epoch || 0;
|
|
2626
|
+
}
|
|
2627
|
+
// The egress that produced *this* refusal is spent: record it so the
|
|
2628
|
+
// next request picks a different entry instead of relearning the
|
|
2629
|
+
// same throttle. A 429 is an unambiguous allowance answer, so it
|
|
2630
|
+
// earns a longer cooldown than a 403 whose cause the bridge cannot
|
|
2631
|
+
// fully separate from a shape problem.
|
|
2632
|
+
recordEgressFailure(egressState, {
|
|
2633
|
+
reason: capacity ? "allowance 429" : `gate ${retryStatus}`,
|
|
2634
|
+
cooldownMs: capacity ? proxy.describe().pool.cooldownMs * 4 : null,
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
// The gate answers the same `403 FreeTierError` for a blocked egress
|
|
2638
|
+
// and for a request whose shape it refuses, so the status line and
|
|
2639
|
+
// message cannot tell the two apart. What can: whether the request
|
|
2640
|
+
// that was actually sent carried the anchors the gate checks. Audit
|
|
2641
|
+
// that here instead of asserting a cause. Measured anchors: a tool
|
|
2642
|
+
// named `read`, and an `opencode/*` User-Agent with a session header.
|
|
2643
|
+
const shape = auditGateShape(upstreamBody, retryHeaders || upstreamHeaders);
|
|
2644
|
+
const shapeOk = shape.hasReadTool && shape.userAgentOk && shape.sessionHeaders.length > 0;
|
|
2645
|
+
const missing = [];
|
|
2646
|
+
if (!shape.hasReadTool) missing.push("tool `read`");
|
|
2647
|
+
if (!shape.userAgentOk) missing.push("an `opencode/*` User-Agent");
|
|
2648
|
+
if (shape.sessionHeaders.length === 0) missing.push("a session header");
|
|
2649
|
+
// Correlation id for this rejection. Upstream returns it on every
|
|
2650
|
+
// response, accepted or refused; it is the only handle a support
|
|
2651
|
+
// request or a bug report can quote.
|
|
2652
|
+
const upstreamLogId = upstreamLogIdOf(upstreamRes);
|
|
2653
|
+
lastGateDiagnostic = {
|
|
2654
|
+
at: Date.now(),
|
|
2655
|
+
kind: "free-tier-gate",
|
|
2656
|
+
status: retryStatus,
|
|
2657
|
+
logId: upstreamLogId,
|
|
2658
|
+
shape,
|
|
2659
|
+
shapeOk,
|
|
2660
|
+
missing,
|
|
2661
|
+
model: targetModel,
|
|
2662
|
+
};
|
|
2663
|
+
|
|
2664
|
+
const verdictText = capacity
|
|
2665
|
+
? "upstream accepted the transport and the request shape and answered with capacity pressure; the free-tier allowance or the model's capacity is exhausted. Retry later or switch model"
|
|
2666
|
+
: retryFreeTierMsg
|
|
2667
|
+
? onNode
|
|
2668
|
+
? "this process is on Node, whose TLS ClientHello differs from the CLI's (measured: Node 0/6, Bun 6/6 from the same IP). Run the bridge with Bun — `bun server.js` — before suspecting an allowance limit"
|
|
2669
|
+
: shapeOk
|
|
2670
|
+
? "the request carried every anchor the gate checks, so this is the egress: the per-IP free-tier allowance is spent or upstream refuses this network. The real CLI gets the same 403 from the same IP when it is"
|
|
2671
|
+
: `this bridge produced a shape the gate refuses — missing ${missing.join(", ")}. That is a bridge bug, not an IP allowance: the same 403 is returned for both, which is why it is audited here`
|
|
2672
|
+
: `no further identity change to apply; ${UPDATE_HINT}`;
|
|
2673
|
+
console.warn(
|
|
2674
|
+
`[opencode-free-bridge] Upstream CLI gate rejected ${
|
|
2675
|
+
skipCooldownRetry
|
|
2676
|
+
? "the request (no retry: cooldown active)"
|
|
2677
|
+
: "the retry with the refreshed identity"
|
|
2678
|
+
} (${retryStatus}${
|
|
2679
|
+
retryStatus !== gateStatus ? `, first attempt ${gateStatus}` : ""
|
|
2680
|
+
}): ${retryMsg} — ${verdictText}` +
|
|
2681
|
+
(upstreamLogId ? ` [x-opencode-log-id: ${upstreamLogId}]` : "")
|
|
2682
|
+
);
|
|
2683
|
+
const outStatus = resolveIpThrottleStatus(upstreamRes.status, retryFreeTierMsg && !capacity);
|
|
2684
|
+
res.writeHead(
|
|
2685
|
+
outStatus,
|
|
2686
|
+
buildIpThrottleHeaders(retryFreeTierMsg && !capacity, {
|
|
2687
|
+
shapeOk,
|
|
2688
|
+
logId: upstreamLogId,
|
|
2689
|
+
})
|
|
2690
|
+
);
|
|
2691
|
+
return res.end(
|
|
2692
|
+
JSON.stringify({
|
|
2693
|
+
error: {
|
|
2694
|
+
// Point at an update only when the shape is plausibly stale.
|
|
2695
|
+
// A budget rejection already carries the newest identity, so
|
|
2696
|
+
// an "update the bridge" hint would send the user the wrong way.
|
|
2697
|
+
message: retryFreeTierMsg ? retryMsg : `${retryMsg} (${UPDATE_HINT})`,
|
|
2698
|
+
type: retryType,
|
|
2699
|
+
code: outStatus,
|
|
2700
|
+
},
|
|
2701
|
+
})
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
lastFailedGateRetryTime = 0;
|
|
2707
|
+
lastIpAbuseAt = 0;
|
|
2708
|
+
// This egress carried a request upstream accepted, so its failure
|
|
2709
|
+
// history is stale: clear it so the next pick treats it as healthy.
|
|
2710
|
+
proxy.markSuccess(egressState.egressUrl);
|
|
2711
|
+
const completionId = `chatcmpl-${crypto.randomBytes(12).toString("hex")}`;
|
|
2712
|
+
const created = Math.floor(Date.now() / 1000);
|
|
2713
|
+
reader = upstreamRes.body.getReader();
|
|
2714
|
+
const decoder = new TextDecoder();
|
|
2715
|
+
let buffer = "";
|
|
2716
|
+
|
|
2717
|
+
// ==========================================
|
|
2718
|
+
// PATH A: Client requested streaming (SSE)
|
|
2719
|
+
// ==========================================
|
|
2720
|
+
if (clientStream) {
|
|
2721
|
+
let sseHeadersSent = false;
|
|
2722
|
+
const writeSse = (payload) => {
|
|
2723
|
+
if (!sseHeadersSent) {
|
|
2724
|
+
sseHeadersSent = true;
|
|
2725
|
+
res.writeHead(200, {
|
|
2726
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2727
|
+
"Cache-Control": "no-cache",
|
|
2728
|
+
"Connection": "keep-alive",
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
res.write(payload);
|
|
2732
|
+
};
|
|
2733
|
+
const emitStreamError = (errMessage, errType) => {
|
|
2734
|
+
console.warn(`[opencode-free-bridge] Upstream failed inside the stream: ${errMessage}`);
|
|
2735
|
+
reader.cancel().catch(() => {});
|
|
2736
|
+
if (!sseHeadersSent) {
|
|
2737
|
+
sseHeadersSent = true;
|
|
2738
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2739
|
+
res.end(
|
|
2740
|
+
JSON.stringify({
|
|
2741
|
+
error: {
|
|
2742
|
+
message: errMessage,
|
|
2743
|
+
type: errType || "upstream_error",
|
|
2744
|
+
code: 502,
|
|
2745
|
+
},
|
|
2746
|
+
})
|
|
2747
|
+
);
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
writeSse(
|
|
2751
|
+
`data: ${JSON.stringify({
|
|
2752
|
+
error: {
|
|
2753
|
+
message: errMessage,
|
|
2754
|
+
type: errType || "upstream_error",
|
|
2755
|
+
code: 502,
|
|
2756
|
+
},
|
|
2757
|
+
})}\n\n`
|
|
2758
|
+
);
|
|
2759
|
+
writeSse("data: [DONE]\n\n");
|
|
2760
|
+
};
|
|
2761
|
+
|
|
2762
|
+
if (useResponses) {
|
|
2763
|
+
const toolIndexMap = new Map();
|
|
2764
|
+
let nextToolIndex = 0;
|
|
2765
|
+
let hasToolCalls = false;
|
|
2766
|
+
let upstreamFinishReason = null;
|
|
2767
|
+
let streamUsage = null;
|
|
2768
|
+
let streamAbortedByError = false;
|
|
2769
|
+
|
|
2770
|
+
const handleResponsesStreamLine = (line) => {
|
|
2771
|
+
if (streamAbortedByError) return;
|
|
2772
|
+
if (!line.startsWith("data: ")) return;
|
|
2773
|
+
const rawJson = line.slice(6).trim();
|
|
2774
|
+
if (!rawJson || rawJson === "[DONE]") return;
|
|
2775
|
+
try {
|
|
2776
|
+
const ev = JSON.parse(rawJson);
|
|
2777
|
+
const inStreamError = extractStreamError(ev);
|
|
2778
|
+
if (inStreamError) {
|
|
2779
|
+
streamAbortedByError = true;
|
|
2780
|
+
emitStreamError(inStreamError, ev?.error?.type);
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
if (ev.type === "response.output_text.delta" && ev.delta) {
|
|
2784
|
+
writeSse(
|
|
2785
|
+
`data: ${JSON.stringify({
|
|
2786
|
+
id: completionId,
|
|
2787
|
+
object: "chat.completion.chunk",
|
|
2788
|
+
created,
|
|
2789
|
+
model: targetModel,
|
|
2790
|
+
choices: [{ index: 0, delta: { content: ev.delta }, finish_reason: null }],
|
|
2791
|
+
})}\n\n`
|
|
2792
|
+
);
|
|
2793
|
+
} else if (
|
|
2794
|
+
(ev.type === "response.reasoning_summary_text.delta" ||
|
|
2795
|
+
ev.type === "response.reasoning_text.delta" ||
|
|
2796
|
+
ev.type === "response.reasoning.delta") &&
|
|
2797
|
+
ev.delta
|
|
2798
|
+
) {
|
|
2799
|
+
writeSse(
|
|
2800
|
+
`data: ${JSON.stringify({
|
|
2801
|
+
id: completionId,
|
|
2802
|
+
object: "chat.completion.chunk",
|
|
2803
|
+
created,
|
|
2804
|
+
model: targetModel,
|
|
2805
|
+
choices: [{ index: 0, delta: { reasoning_content: ev.delta }, finish_reason: null }],
|
|
2806
|
+
})}\n\n`
|
|
2807
|
+
);
|
|
2808
|
+
} else if (ev.type === "response.output_item.added" && ev.item?.type === "function_call") {
|
|
2809
|
+
hasToolCalls = true;
|
|
2810
|
+
const key =
|
|
2811
|
+
ev.output_index !== undefined
|
|
2812
|
+
? String(ev.output_index)
|
|
2813
|
+
: ev.item.call_id || ev.item.id || String(nextToolIndex);
|
|
2814
|
+
let toolIdx = toolIndexMap.get(key);
|
|
2815
|
+
if (toolIdx === undefined) {
|
|
2816
|
+
toolIdx = nextToolIndex++;
|
|
2817
|
+
toolIndexMap.set(key, toolIdx);
|
|
2818
|
+
if (ev.item.call_id) toolIndexMap.set(ev.item.call_id, toolIdx);
|
|
2819
|
+
if (ev.item.id) toolIndexMap.set(ev.item.id, toolIdx);
|
|
2820
|
+
if (ev.output_index !== undefined) toolIndexMap.set(String(ev.output_index), toolIdx);
|
|
2821
|
+
}
|
|
2822
|
+
const restoredName = restoreClientToolName(ev.item.name, aliasToOriginal);
|
|
2823
|
+
writeSse(
|
|
2824
|
+
`data: ${JSON.stringify({
|
|
2825
|
+
id: completionId,
|
|
2826
|
+
object: "chat.completion.chunk",
|
|
2827
|
+
created,
|
|
2828
|
+
model: targetModel,
|
|
2829
|
+
choices: [
|
|
2830
|
+
{
|
|
2831
|
+
index: 0,
|
|
2832
|
+
delta: {
|
|
2833
|
+
tool_calls: [
|
|
2834
|
+
{
|
|
2835
|
+
index: toolIdx,
|
|
2836
|
+
id: ev.item.call_id || ev.item.id || `call_${toolIdx}`,
|
|
2837
|
+
type: "function",
|
|
2838
|
+
function: { name: restoredName, arguments: "" },
|
|
2839
|
+
},
|
|
2840
|
+
],
|
|
2841
|
+
},
|
|
2842
|
+
finish_reason: null,
|
|
2843
|
+
},
|
|
2844
|
+
],
|
|
2845
|
+
})}\n\n`
|
|
2846
|
+
);
|
|
2847
|
+
} else if (ev.type === "response.function_call_arguments.delta" && ev.delta) {
|
|
2848
|
+
const key = ev.output_index !== undefined ? String(ev.output_index) : ev.item_id || ev.call_id;
|
|
2849
|
+
const toolIdx = key !== undefined && toolIndexMap.has(key) ? toolIndexMap.get(key) : 0;
|
|
2850
|
+
writeSse(
|
|
2851
|
+
`data: ${JSON.stringify({
|
|
2852
|
+
id: completionId,
|
|
2853
|
+
object: "chat.completion.chunk",
|
|
2854
|
+
created,
|
|
2855
|
+
model: targetModel,
|
|
2856
|
+
choices: [
|
|
2857
|
+
{
|
|
2858
|
+
index: 0,
|
|
2859
|
+
delta: {
|
|
2860
|
+
tool_calls: [{ index: toolIdx, function: { arguments: ev.delta } }],
|
|
2861
|
+
},
|
|
2862
|
+
finish_reason: null,
|
|
2863
|
+
},
|
|
2864
|
+
],
|
|
2865
|
+
})}\n\n`
|
|
2866
|
+
);
|
|
2867
|
+
} else if (ev.type === "response.completed" || ev.type === "response.incomplete") {
|
|
2868
|
+
upstreamFinishReason = extractResponsesFinishReason(ev, hasToolCalls);
|
|
2869
|
+
if (ev.response?.usage) {
|
|
2870
|
+
streamUsage = {
|
|
2871
|
+
prompt_tokens: ev.response.usage.input_tokens || 0,
|
|
2872
|
+
completion_tokens: ev.response.usage.output_tokens || 0,
|
|
2873
|
+
total_tokens: ev.response.usage.total_tokens || 0,
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
} catch {}
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2880
|
+
await consumeSseLines(reader, decoder, handleResponsesStreamLine);
|
|
2881
|
+
if (streamAbortedByError) {
|
|
2882
|
+
if (!res.writableEnded) res.end();
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
const finalFinishReason = upstreamFinishReason || (hasToolCalls ? "tool_calls" : "stop");
|
|
2887
|
+
writeSse(
|
|
2888
|
+
`data: ${JSON.stringify({
|
|
2889
|
+
id: completionId,
|
|
2890
|
+
object: "chat.completion.chunk",
|
|
2891
|
+
created,
|
|
2892
|
+
model: targetModel,
|
|
2893
|
+
choices: [{ index: 0, delta: {}, finish_reason: finalFinishReason }],
|
|
2894
|
+
})}\n\n`
|
|
2895
|
+
);
|
|
2896
|
+
if (body.stream_options?.include_usage && streamUsage) {
|
|
2897
|
+
writeSse(
|
|
2898
|
+
`data: ${JSON.stringify({
|
|
2899
|
+
id: completionId,
|
|
2900
|
+
object: "chat.completion.chunk",
|
|
2901
|
+
created,
|
|
2902
|
+
model: targetModel,
|
|
2903
|
+
choices: [],
|
|
2904
|
+
usage: streamUsage,
|
|
2905
|
+
})}\n\n`
|
|
2906
|
+
);
|
|
2907
|
+
}
|
|
2908
|
+
writeSse("data: [DONE]\n\n");
|
|
2909
|
+
return res.end();
|
|
2910
|
+
} else {
|
|
2911
|
+
// Chat Completions API SSE stream
|
|
2912
|
+
const hasAliases = aliasToOriginal.size > 0;
|
|
2913
|
+
let streamAbortedByError = false;
|
|
2914
|
+
const handleChatStreamLine = (line) => {
|
|
2915
|
+
if (streamAbortedByError) return;
|
|
2916
|
+
if (!line.startsWith("data: ")) return;
|
|
2917
|
+
const raw = line.slice(6).trim();
|
|
2918
|
+
if (!raw) return;
|
|
2919
|
+
if (raw === "[DONE]") {
|
|
2920
|
+
writeSse("data: [DONE]\n\n");
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
// A failure inside the stream is not a completion. If no chunk has
|
|
2924
|
+
// been sent yet, return HTTP 502 JSON so any router or harness can
|
|
2925
|
+
// fail over; if chunks were already sent, emit an SSE error event.
|
|
2926
|
+
if (raw.includes('"error"')) try {
|
|
2927
|
+
const maybe = JSON.parse(raw);
|
|
2928
|
+
const streamErr = extractStreamError(maybe);
|
|
2929
|
+
if (streamErr) {
|
|
2930
|
+
streamAbortedByError = true;
|
|
2931
|
+
emitStreamError(streamErr, maybe?.error?.type);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
} catch {}
|
|
2935
|
+
// Zero-parse fast path: when no client tools are aliased and the chunk
|
|
2936
|
+
// carries no `"reasoning":` field to normalize, forward raw SSE bytes directly.
|
|
2937
|
+
if (!hasAliases && !raw.includes('"reasoning"')) {
|
|
2938
|
+
writeSse(`data: ${raw}\n\n`);
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
try {
|
|
2942
|
+
const chunk = JSON.parse(raw);
|
|
2943
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
2944
|
+
if (delta && delta.reasoning && !delta.reasoning_content) {
|
|
2945
|
+
delta.reasoning_content = delta.reasoning;
|
|
2946
|
+
}
|
|
2947
|
+
if (hasAliases && Array.isArray(delta?.tool_calls)) {
|
|
2948
|
+
for (const tc of delta.tool_calls) {
|
|
2949
|
+
if (tc.function?.name) {
|
|
2950
|
+
tc.function.name = restoreClientToolName(tc.function.name, aliasToOriginal);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
writeSse(`data: ${JSON.stringify(chunk)}\n\n`);
|
|
2955
|
+
} catch {
|
|
2956
|
+
writeSse(`data: ${raw}\n\n`);
|
|
2957
|
+
}
|
|
2958
|
+
};
|
|
2959
|
+
|
|
2960
|
+
await consumeSseLines(reader, decoder, handleChatStreamLine);
|
|
2961
|
+
if (!res.writableEnded) {
|
|
2962
|
+
if (!sseHeadersSent) writeSse("data: [DONE]\n\n");
|
|
2963
|
+
res.end();
|
|
2964
|
+
}
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
// ==========================================
|
|
2970
|
+
// PATH B: Client requested non-streaming (JSON)
|
|
2971
|
+
// ==========================================
|
|
2972
|
+
let assistantText = "";
|
|
2973
|
+
let reasoningText = "";
|
|
2974
|
+
const toolCalls = [];
|
|
2975
|
+
const nonStreamToolMap = new Map();
|
|
2976
|
+
let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
2977
|
+
let upstreamFinishReason = null;
|
|
2978
|
+
let streamError = null;
|
|
2979
|
+
|
|
2980
|
+
const handleNonStreamLine = (line) => {
|
|
2981
|
+
if (!line.startsWith("data: ")) return;
|
|
2982
|
+
const rawJson = line.slice(6).trim();
|
|
2983
|
+
if (!rawJson || rawJson === "[DONE]") return;
|
|
2984
|
+
|
|
2985
|
+
try {
|
|
2986
|
+
const ev = JSON.parse(rawJson);
|
|
2987
|
+
|
|
2988
|
+
// A 200 followed by an in-stream error is a failure, not an empty
|
|
2989
|
+
// completion. Without this the client would receive content: null
|
|
2990
|
+
// with finish_reason "stop".
|
|
2991
|
+
const inStreamError = extractStreamError(ev);
|
|
2992
|
+
if (inStreamError) {
|
|
2993
|
+
streamError = inStreamError;
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
if (useResponses) {
|
|
2998
|
+
if (ev.type === "response.output_text.delta" && ev.delta) {
|
|
2999
|
+
assistantText += ev.delta;
|
|
3000
|
+
} else if (
|
|
3001
|
+
(ev.type === "response.reasoning_summary_text.delta" ||
|
|
3002
|
+
ev.type === "response.reasoning_text.delta" ||
|
|
3003
|
+
ev.type === "response.reasoning.delta") &&
|
|
3004
|
+
ev.delta
|
|
3005
|
+
) {
|
|
3006
|
+
reasoningText += ev.delta;
|
|
3007
|
+
} else if (ev.type === "response.output_item.added" && ev.item?.type === "function_call") {
|
|
3008
|
+
const key =
|
|
3009
|
+
ev.output_index !== undefined
|
|
3010
|
+
? String(ev.output_index)
|
|
3011
|
+
: ev.item.call_id || ev.item.id || String(toolCalls.length);
|
|
3012
|
+
const tc = {
|
|
3013
|
+
id: ev.item.call_id || ev.item.id || `call_${toolCalls.length}`,
|
|
3014
|
+
type: "function",
|
|
3015
|
+
function: {
|
|
3016
|
+
name: restoreClientToolName(ev.item.name, aliasToOriginal),
|
|
3017
|
+
arguments: "",
|
|
3018
|
+
},
|
|
3019
|
+
};
|
|
3020
|
+
toolCalls.push(tc);
|
|
3021
|
+
nonStreamToolMap.set(key, tc);
|
|
3022
|
+
if (ev.item.call_id) nonStreamToolMap.set(ev.item.call_id, tc);
|
|
3023
|
+
if (ev.item.id) nonStreamToolMap.set(ev.item.id, tc);
|
|
3024
|
+
if (ev.output_index !== undefined) nonStreamToolMap.set(String(ev.output_index), tc);
|
|
3025
|
+
} else if (ev.type === "response.function_call_arguments.delta" && ev.delta) {
|
|
3026
|
+
const key =
|
|
3027
|
+
ev.output_index !== undefined ? String(ev.output_index) : ev.item_id || ev.call_id;
|
|
3028
|
+
const targetTc =
|
|
3029
|
+
key !== undefined && nonStreamToolMap.has(key)
|
|
3030
|
+
? nonStreamToolMap.get(key)
|
|
3031
|
+
: toolCalls[toolCalls.length - 1];
|
|
3032
|
+
if (targetTc) targetTc.function.arguments += ev.delta;
|
|
3033
|
+
} else if (ev.type === "response.completed" || ev.type === "response.incomplete") {
|
|
3034
|
+
upstreamFinishReason = extractResponsesFinishReason(ev, toolCalls.length > 0);
|
|
3035
|
+
if (ev.response?.usage) {
|
|
3036
|
+
usage = {
|
|
3037
|
+
prompt_tokens: ev.response.usage.input_tokens || 0,
|
|
3038
|
+
completion_tokens: ev.response.usage.output_tokens || 0,
|
|
3039
|
+
total_tokens: ev.response.usage.total_tokens || 0,
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
} else {
|
|
3044
|
+
// Chat completions format parsing
|
|
3045
|
+
const choice = ev.choices?.[0];
|
|
3046
|
+
if (choice?.finish_reason) {
|
|
3047
|
+
upstreamFinishReason = choice.finish_reason;
|
|
3048
|
+
}
|
|
3049
|
+
const delta = choice?.delta;
|
|
3050
|
+
if (delta?.content) assistantText += delta.content;
|
|
3051
|
+
if (delta?.reasoning) reasoningText += delta.reasoning;
|
|
3052
|
+
else if (delta?.reasoning_content) reasoningText += delta.reasoning_content;
|
|
3053
|
+
if (Array.isArray(delta?.tool_calls)) {
|
|
3054
|
+
for (const tc of delta.tool_calls) {
|
|
3055
|
+
const idx = tc.index ?? toolCalls.length;
|
|
3056
|
+
if (!toolCalls[idx]) {
|
|
3057
|
+
toolCalls[idx] = {
|
|
3058
|
+
id: tc.id || `call_${idx}`,
|
|
3059
|
+
type: "function",
|
|
3060
|
+
function: {
|
|
3061
|
+
name: restoreClientToolName(tc.function?.name || "", aliasToOriginal),
|
|
3062
|
+
arguments: "",
|
|
3063
|
+
},
|
|
3064
|
+
};
|
|
3065
|
+
} else {
|
|
3066
|
+
if (tc.id && toolCalls[idx].id.startsWith("call_")) {
|
|
3067
|
+
toolCalls[idx].id = tc.id;
|
|
3068
|
+
}
|
|
3069
|
+
if (tc.function?.name && !toolCalls[idx].function.name) {
|
|
3070
|
+
toolCalls[idx].function.name = restoreClientToolName(
|
|
3071
|
+
tc.function.name,
|
|
3072
|
+
aliasToOriginal
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
if (tc.function?.arguments) {
|
|
3077
|
+
toolCalls[idx].function.arguments += tc.function.arguments;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
if (ev.usage && typeof ev.usage === "object") {
|
|
3082
|
+
usage = {
|
|
3083
|
+
prompt_tokens: ev.usage.prompt_tokens || 0,
|
|
3084
|
+
completion_tokens: ev.usage.completion_tokens || 0,
|
|
3085
|
+
total_tokens: ev.usage.total_tokens || 0,
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
} catch {}
|
|
3090
|
+
};
|
|
3091
|
+
|
|
3092
|
+
await consumeSseLines(reader, decoder, handleNonStreamLine);
|
|
3093
|
+
|
|
3094
|
+
if (streamError) {
|
|
3095
|
+
console.warn(`[opencode-free-bridge] Upstream failed inside the stream: ${streamError}`);
|
|
3096
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
3097
|
+
return res.end(
|
|
3098
|
+
JSON.stringify({
|
|
3099
|
+
error: { message: streamError, type: "upstream_error", code: 502 },
|
|
3100
|
+
})
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
const validToolCalls = toolCalls.filter(Boolean);
|
|
3105
|
+
const messageObj = { role: "assistant", content: assistantText || null };
|
|
3106
|
+
if (reasoningText) messageObj.reasoning_content = reasoningText;
|
|
3107
|
+
if (validToolCalls.length > 0) messageObj.tool_calls = validToolCalls;
|
|
3108
|
+
|
|
3109
|
+
let finishReason = upstreamFinishReason || "stop";
|
|
3110
|
+
if (
|
|
3111
|
+
validToolCalls.length > 0 &&
|
|
3112
|
+
finishReason !== "length" &&
|
|
3113
|
+
finishReason !== "content_filter"
|
|
3114
|
+
) {
|
|
3115
|
+
finishReason = "tool_calls";
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3119
|
+
return res.end(
|
|
3120
|
+
JSON.stringify({
|
|
3121
|
+
id: completionId,
|
|
3122
|
+
object: "chat.completion",
|
|
3123
|
+
created,
|
|
3124
|
+
model: targetModel,
|
|
3125
|
+
choices: [
|
|
3126
|
+
{
|
|
3127
|
+
index: 0,
|
|
3128
|
+
message: messageObj,
|
|
3129
|
+
finish_reason: finishReason,
|
|
3130
|
+
},
|
|
3131
|
+
],
|
|
3132
|
+
usage,
|
|
3133
|
+
})
|
|
3134
|
+
);
|
|
3135
|
+
} catch (err) {
|
|
3136
|
+
if (reader) {
|
|
3137
|
+
reader.cancel().catch(() => {});
|
|
3138
|
+
}
|
|
3139
|
+
if (clientDisconnected || res.writableEnded) {
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
if (timedOut) {
|
|
3143
|
+
if (!res.headersSent) {
|
|
3144
|
+
res.writeHead(504, { "Content-Type": "application/json" });
|
|
3145
|
+
return res.end(
|
|
3146
|
+
JSON.stringify({
|
|
3147
|
+
error: {
|
|
3148
|
+
message: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS}ms`,
|
|
3149
|
+
type: "timeout_error",
|
|
3150
|
+
code: 504,
|
|
3151
|
+
},
|
|
3152
|
+
})
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
return res.end();
|
|
3156
|
+
}
|
|
3157
|
+
console.error("[opencode-free-bridge] exception:", err);
|
|
3158
|
+
if (!res.headersSent) {
|
|
3159
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3160
|
+
return res.end(
|
|
3161
|
+
JSON.stringify({
|
|
3162
|
+
error: { message: err.message || "Internal server error", type: "server_error" },
|
|
3163
|
+
})
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
return res.end();
|
|
3167
|
+
} finally {
|
|
3168
|
+
clearTimeout(timeoutId);
|
|
3169
|
+
res.off("close", onClose);
|
|
3170
|
+
}
|
|
3171
|
+
})();
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
3176
|
+
res.end(JSON.stringify({ error: { message: "Not found", type: "invalid_request_error" } }));
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
async function consumeSseLines(reader, decoder, onLine) {
|
|
3180
|
+
let buffer = "";
|
|
3181
|
+
while (true) {
|
|
3182
|
+
const { done, value } = await reader.read();
|
|
3183
|
+
if (done) {
|
|
3184
|
+
buffer += decoder.decode();
|
|
3185
|
+
if (buffer.trim()) {
|
|
3186
|
+
let start = 0;
|
|
3187
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
3188
|
+
if (buffer.charCodeAt(i) === 10) {
|
|
3189
|
+
const line =
|
|
3190
|
+
i > start && buffer.charCodeAt(i - 1) === 13
|
|
3191
|
+
? buffer.slice(start, i - 1)
|
|
3192
|
+
: buffer.slice(start, i);
|
|
3193
|
+
onLine(line);
|
|
3194
|
+
start = i + 1;
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
if (start < buffer.length) {
|
|
3198
|
+
const tail = buffer.endsWith("\r") ? buffer.slice(start, -1) : buffer.slice(start);
|
|
3199
|
+
onLine(tail);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
break;
|
|
3203
|
+
}
|
|
3204
|
+
buffer += decoder.decode(value, { stream: true });
|
|
3205
|
+
let start = 0;
|
|
3206
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
3207
|
+
if (buffer.charCodeAt(i) === 10) {
|
|
3208
|
+
const line =
|
|
3209
|
+
i > start && buffer.charCodeAt(i - 1) === 13
|
|
3210
|
+
? buffer.slice(start, i - 1)
|
|
3211
|
+
: buffer.slice(start, i);
|
|
3212
|
+
onLine(line);
|
|
3213
|
+
start = i + 1;
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
buffer = start === 0 ? buffer : buffer.slice(start);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
const server = http.createServer(handleRequest);
|
|
3221
|
+
|
|
3222
|
+
// Only auto-start when executed directly (`node server.js`), so unit tests can
|
|
3223
|
+
// require("./server") without binding the default port or starting timers.
|
|
3224
|
+
// The transport fingerprint is a property of the HTTP runtime, not of this code.
|
|
3225
|
+
// Measured from one IP in one minute with identical headers and body: the real
|
|
3226
|
+
// CLI and this bridge on Bun both answer 200, while this bridge on Node and curl
|
|
3227
|
+
// answer 403. The CLI ships as a Bun binary, and the gate inspects its
|
|
3228
|
+
// ClientHello. Node therefore cannot serve live traffic, so it is refused at
|
|
3229
|
+
// startup rather than left running and failing every request.
|
|
3230
|
+
const ON_BUN = typeof Bun !== "undefined";
|
|
3231
|
+
const ALLOW_NODE = process.env.ALLOW_NODE === "1";
|
|
3232
|
+
|
|
3233
|
+
function refuseNodeRuntime() {
|
|
3234
|
+
const lines = [
|
|
3235
|
+
"",
|
|
3236
|
+
" opencode-free-bridge requires Bun.",
|
|
3237
|
+
"",
|
|
3238
|
+
" This process is Node, whose TLS transport the free-tier gate refuses:",
|
|
3239
|
+
" measured 0/6 requests answered on Node vs 6/6 on Bun, from the same IP,",
|
|
3240
|
+
" in the same minute, with byte-identical headers and body.",
|
|
3241
|
+
"",
|
|
3242
|
+
" The CLI ships as a Bun binary, so only Bun reproduces its transport.",
|
|
3243
|
+
"",
|
|
3244
|
+
" Install Bun: curl -fsSL https://bun.sh/install | bash",
|
|
3245
|
+
" Then run: bun server.js",
|
|
3246
|
+
"",
|
|
3247
|
+
" For offline work (tests, drills, inspecting /v1/models) set ALLOW_NODE=1.",
|
|
3248
|
+
" Live chat requests will still be refused with 403 by upstream.",
|
|
3249
|
+
"",
|
|
3250
|
+
];
|
|
3251
|
+
console.error(lines.join("\n"));
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
if (require.main === module) {
|
|
3255
|
+
if (!ON_BUN && !ALLOW_NODE) {
|
|
3256
|
+
refuseNodeRuntime();
|
|
3257
|
+
process.exit(1);
|
|
3258
|
+
}
|
|
3259
|
+
server.listen(PORT, HOST, () => {
|
|
3260
|
+
console.log(`[opencode-free-bridge] v${VERSION} listening on http://${HOST}:${PORT}`);
|
|
3261
|
+
if (!ON_BUN) {
|
|
3262
|
+
console.log(
|
|
3263
|
+
"[opencode-free-bridge] WARNING: ALLOW_NODE=1 — running on Node. Real chat requests will " +
|
|
3264
|
+
"be refused by the free-tier gate with 403. This mode is for offline work against a " +
|
|
3265
|
+
"mocked upstream, not for live traffic."
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
});
|
|
3269
|
+
// Repair a half-installed tree (an older self-updater that did not know about
|
|
3270
|
+
// proxy.js/panel.js) after the socket is open, so a slow or absent network
|
|
3271
|
+
// never delays startup. The panel reads the current binding per request, so
|
|
3272
|
+
// it starts working as soon as the repair lands.
|
|
3273
|
+
repairMissingModules().catch(() => {});
|
|
3274
|
+
// Discovery runs in background: /v1/models is live as soon as upstream
|
|
3275
|
+
// answers. Boot never blocks on network.
|
|
3276
|
+
discoverModels().catch(() => {});
|
|
3277
|
+
// Model list rotates on upstream's schedule, so it keeps a timer. The
|
|
3278
|
+
// IDENTITY does not: it is refreshed lazily from the request path (see
|
|
3279
|
+
// identity.ensureFresh), so an idle bridge makes no network calls at all.
|
|
3280
|
+
setInterval(discoverModels, DISCOVERY_TTL_MS).unref();
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
module.exports = {
|
|
3284
|
+
VERSION,
|
|
3285
|
+
// Kept for callers and tests written against the old frozen constants. These
|
|
3286
|
+
// are live views of the resolved profile, not snapshots.
|
|
3287
|
+
get FINGERPRINT_TOOL_NAMES() {
|
|
3288
|
+
return getFingerprintNames();
|
|
3289
|
+
},
|
|
3290
|
+
get FINGERPRINT_TOOL_DEFINITIONS() {
|
|
3291
|
+
return getFingerprintDefinitions();
|
|
3292
|
+
},
|
|
3293
|
+
get CANONICAL_TOOLS_CHAT() {
|
|
3294
|
+
return getCanonicalTools(false);
|
|
3295
|
+
},
|
|
3296
|
+
get CANONICAL_TOOLS_RESPONSES() {
|
|
3297
|
+
return getCanonicalTools(true);
|
|
3298
|
+
},
|
|
3299
|
+
MAX_SESSIONS,
|
|
3300
|
+
SESSION_TTL_MS,
|
|
3301
|
+
sessionCache,
|
|
3302
|
+
createId,
|
|
3303
|
+
isValidV2UserAgent,
|
|
3304
|
+
normalizeModel,
|
|
3305
|
+
isResponsesModel,
|
|
3306
|
+
markProtocolSupport,
|
|
3307
|
+
__resetProtocolLanes,
|
|
3308
|
+
extractContent,
|
|
3309
|
+
transformToResponsesInput,
|
|
3310
|
+
transformChatMessages,
|
|
3311
|
+
prepareToolsWithFingerprint,
|
|
3312
|
+
prepareResponsesTools,
|
|
3313
|
+
prepareChatTools,
|
|
3314
|
+
restoreClientToolName,
|
|
3315
|
+
buildUpstreamPayload,
|
|
3316
|
+
isNewerVersion,
|
|
3317
|
+
checkAndApplyUpdate,
|
|
3318
|
+
resolveEndpoints,
|
|
3319
|
+
buildUpstreamHeaders,
|
|
3320
|
+
getProjectId,
|
|
3321
|
+
extractResponsesFinishReason,
|
|
3322
|
+
extractStreamError,
|
|
3323
|
+
getPersistentSessionId,
|
|
3324
|
+
discoverModels,
|
|
3325
|
+
handleRequest,
|
|
3326
|
+
server,
|
|
3327
|
+
shortText,
|
|
3328
|
+
messageSignatureSample,
|
|
3329
|
+
transformResponsesContent,
|
|
3330
|
+
extractSystemInstructions,
|
|
3331
|
+
isLocalOrigin,
|
|
3332
|
+
isFreeTierCliGateError,
|
|
3333
|
+
consumeSseLines,
|
|
3334
|
+
readRequestBody,
|
|
3335
|
+
refreshCliVersion,
|
|
3336
|
+
identity,
|
|
3337
|
+
__getUserAgent: () => currentUserAgent,
|
|
3338
|
+
__syncUserAgent: syncUserAgent,
|
|
3339
|
+
__getGateDiagnostic: () => lastGateDiagnostic,
|
|
3340
|
+
__getLastUpstreamError: () => lastUpstreamError,
|
|
3341
|
+
__auditGateShape: auditGateShape,
|
|
3342
|
+
gateSafeUserAgent,
|
|
3343
|
+
DISCOVERY_TTL_MS,
|
|
3344
|
+
CLI_VERSION_TTL_MS,
|
|
3345
|
+
__setLastDiscoveryTime: (v) => { lastDiscoveryTime = v; },
|
|
3346
|
+
__setActiveModels: (m) => {
|
|
3347
|
+
activeModels = Array.isArray(m) ? m : [];
|
|
3348
|
+
rebuildDiscoveryPayloads();
|
|
3349
|
+
},
|
|
3350
|
+
__getDiscoveryState: () => ({ inFlight: !!discoveryInFlight, lastDiscoveryTime, activeModels }),
|
|
3351
|
+
__setLastCliVersionCheckTime: (v) => { lastCliVersionCheckTime = v; },
|
|
3352
|
+
__getCliVersionState: () => ({ lastCliVersionCheckTime }),
|
|
3353
|
+
__setLastUpdateCheck: (v) => { lastUpdateCheck = v; },
|
|
3354
|
+
__localRequires: localRequires,
|
|
3355
|
+
__repairMissingModules: repairMissingModules,
|
|
3356
|
+
__adminRequestError: adminRequestError,
|
|
3357
|
+
};
|