Fork of mseptiaan/opencode-qwencode-oauth v1.0.3
Qwen OAuth authentication plugin for OpenCode with PKCE device-flow auth, multi-account rotation, proactive token refresh, and automatic OpenAI Responses API ↔ Chat Completions translation.
When installed as an OpenCode plugin, this intercepts all fetch requests made to the qwen provider and:
- Authenticates via Qwen's OAuth 2.0 Device Authorization flow with PKCE (
S256code challenge) againstchat.qwen.ai - Manages tokens — proactively refreshes access tokens before expiry (default 300s window), handles
invalid_grant/invalid_tokenby marking accounts for re-auth - Rotates accounts across multiple Qwen logins using one of three strategies
- Rate-limits itself — detects 429/5xx responses, backs off per reason tier (
QUOTA_EXHAUSTED,RATE_LIMIT_EXCEEDED,SERVER_ERROR), respectsretry-afterheaders - Translates APIs — rewrites OpenAI Responses API (
/responses) requests to Qwen's Chat Completions (/chat/completions) and transforms responses back, including SSE streaming - Sanitizes headers — strips
OpenAI-Betaandx-session-affinity, sets DashScope-specific headers
OpenCode ──fetch()──> QwenOAuthPlugin ──> Qwen API (portal.qwen.ai/v1)
│
├── OAuth: chat.qwen.ai (PKCE device flow)
├── Accounts: ~/.config/opencode/qwen-auth-accounts.json
├── Rotation: health scores + token bucket + LRU
└── Transform: /responses ↔ /chat/completions
{
"plugin": ["opencode-qwencode-oauth"],
"provider": {
"qwen": {
"npm": "@ai-sdk/openai",
"options": {
"baseURL": "https://portal.qwen.ai/v1",
"compatibility": "strict"
}
}
}
}Then run /connect in OpenCode. A browser window opens — authenticate with Qwen, and the plugin polls the device token endpoint until approved.
npx opencode-qwencode-oauth install
bunx opencode-qwencode-oauth install --globalCreates a backup of your existing config, then writes the plugin entry and default qwen provider to opencode.json.
Configured via rotation_strategy in .opencode/qwen.json or env QWEN_ROTATION_STRATEGY.
| Strategy | Behavior |
|---|---|
hybrid (default) |
Multi-signal: health score ×2 + token balance (0–500) + LRU freshness (0–360). Filters rate-limited, score <50, and token-exhausted accounts. |
round-robin |
Cycles through accounts. Skips rate-limited/reauth-required. |
sequential |
Prefers activeIndex, walks forward. |
Each account starts at 70/100. Scores recover passively at +2/hour. Events adjust:
- Success: +1
- Rate-limit (429): −10
- Failure (5xx, auth): −20
Accounts below min_usable (default 50) are deprioritized by hybrid selection.
Default 50 tokens per account, regenerating at 6/minute. Each request costs 1 token. Prevents hammering Qwen's API before their rate limiter kicks in.
Enabled by default (proactive_refresh: true). Refreshes the access token when it's within refresh_window_seconds (default 300) of expiry. The refreshed token is persisted both to local storage and synced back to OpenCode via client.auth.set().
OpenCode uses the OpenAI Responses API (/responses). Qwen speaks Chat Completions (/chat/completions). This plugin bridges them:
Request (/responses → /chat/completions):
input→messagesinstructions→systemmessage prependedmax_output_tokens→max_tokensinput_image/input_audio/input_text→image_url/textcontent partsdeveloperrole →systemrolefunction_call_output→toolrole messages- Tool format normalization (flat → nested
functionobject) - Injects
stream_options: { include_usage: true }andvl_high_resolution_images: true
Response (Chat Completions → Responses):
- Non-streaming: Rewrites
choices[0].messageintooutput[].messagewithoutput_textparts,function_callitems, and usage mapping - SSE streaming: Real-time transform via
TransformStream— emitsresponse.created,response.output_item.added,response.content_part.added,response.output_text.delta,response.function_call_arguments.delta, andresponse.completedevents
Layered, each overriding the previous:
- Defaults (hardcoded in
src/constants.ts) - User config —
~/.config/opencode/qwen.json(Linux/macOS) or%APPDATA%/opencode/qwen.json(Windows) - Project config —
.opencode/qwen.jsonin the project directory - Environment variables —
QWEN_OAUTH_CLIENT_ID,QWEN_OAUTH_BASE_URL,QWEN_API_BASE_URL,QWEN_ROTATION_STRATEGY,QWEN_PROACTIVE_REFRESH,QWEN_REFRESH_WINDOW_SECONDS,QWEN_MAX_RATE_LIMIT_WAIT_SECONDS,QWEN_QUIET_MODE,QWEN_PID_OFFSET_ENABLED
{
client_id: string, // default: "f0304373b74a44d2b584a3fb70ca9e56"
oauth_base_url: string, // default: "https://chat.qwen.ai"
base_url: string, // default: "https://portal.qwen.ai/v1"
rotation_strategy: "hybrid" | "round-robin" | "sequential",
proactive_refresh: boolean, // default: true
refresh_window_seconds: number, // default: 300
max_rate_limit_wait_seconds: number, // default: 300
quiet_mode: boolean, // default: false
pid_offset_enabled: boolean, // default: false
health_score?: {
initial: number, // default: 70
success_reward: number, // default: 1
rate_limit_penalty: number, // default: -10
failure_penalty: number, // default: -20
recovery_rate_per_hour: number, // default: 2
min_usable: number, // default: 50
},
token_bucket?: {
max_tokens: number, // default: 50
regeneration_rate_per_minute: number // default: 6
}
}All state stored in the OpenCode config directory:
| File | Purpose |
|---|---|
qwen-auth-accounts.json |
Refresh tokens, access tokens, expiry, rate-limit reset timestamps, per-account health metrics |
qwen-auth-tracker-state.json |
Health score tracker and token bucket state |
Writes are atomic: temp file + rename. File locking via mkdir-based advisory locks (10s stale threshold, 5 retries with exponential backoff).
- Client generates a PKCE code verifier (32 random bytes, base64url) and S256 challenge
- POST to
/api/v1/oauth2/device/codewithclient_id,scope,code_challenge,code_challenge_method=S256 - Returns
device_code,user_code,verification_uri,verification_uri_complete - Polls
/api/v1/oauth2/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeuntil:- Success: receives
access_token,refresh_token,expires_in,resource_url authorization_pending: retry after intervalslow_down: increase interval by 5sexpired_token: fail
- Success: receives
- Token refresh: POST to same endpoint with
grant_type=refresh_token
| Reason | Detection | Backoff Sequence |
|---|---|---|
| QUOTA_EXHAUSTED | x-error-code header |
60s → 300s → 1800s |
| RATE_LIMIT_EXCEEDED | x-error-code header |
30s → 60s |
| SERVER_ERROR | 5xx status or header | 20s → 40s |
| UNKNOWN | Fallback | 60s |
retry-after-ms and retry-after response headers take precedence over tier defaults.
bun install
tsc --noEmit # typecheck
bun test # run 135+ tests
bun test --watch # watch mode
bun test:e2e # end-to-end against mock server
bun run build # tsc build to dist/
bun run lint # biome checkCI runs on ubuntu, macOS, and Windows via GitHub Actions.
src/
├── cli/install.ts # Interactive installer CLI
├── constants.ts # Qwen API endpoints & defaults
├── plugin.ts # Plugin entry point & fetch interceptor
├── plugin/
│ ├── account.ts # Account storage, selection, health metrics
│ ├── auth.ts # OAuth type guards & expiry checks
│ ├── config/ # Schema (zod), loader, env overrides
│ ├── logger.ts # Namespaced debug logger
│ ├── rotation.ts # Health scores, token bucket, hybrid selection
│ ├── token.ts # Token refresh orchestration
│ └── types.ts # Plugin type definitions
├── qwen/
│ └── oauth.ts # PKCE device flow & token refresh HTTP
├── transform/
│ ├── header.ts # Header normalization & DashScope injection
│ ├── request.ts # Responses → Chat Completions transform
│ ├── response.ts # Chat Completions → Responses (non-streaming)
│ └── sse.ts # SSE stream transform (streaming)
└── types/
└── prompts.d.ts # Module declaration for prompts package
test/
├── account.test.ts
├── auth.test.ts
├── cli.test.ts
├── e2e.test.ts
├── migration.test.ts
├── mock-server/server.ts
├── rotation.test.ts
├── token.test.ts
├── transform.test.ts
└── url.test.ts
MIT