Skip to content

Commit 6b8d445

Browse files
committed
env OPENCODE_API_KEY prioritizes over the stored api key
Signed-off-by: weizhoublue <weizhou.lan@daocloud.io>
1 parent 940c387 commit 6b8d445

6 files changed

Lines changed: 337 additions & 2 deletions

File tree

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: welan release
1+
name: a welan release
22

33
# Fork patch author: only contiguous commits at the tip of origin/welan authored by this
44
# name are cherry-picked onto the upstream release tag.
@@ -195,6 +195,11 @@ jobs:
195195
env:
196196
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false"
197197

198+
- name: Run env key priority tests
199+
working-directory: packages/opencode
200+
timeout-minutes: 5
201+
run: bun test --timeout 60000 test/provider/provider-env-key-priority.test.ts
202+
198203
- name: Build CLI binaries
199204
run: OPENCODE_VERSION="$BUILD_VERSION" ./packages/opencode/script/build.ts
200205

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Env Var API Key Priority Over Stored Key Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Make environment variable API keys (e.g. `OPENCODE_API_KEY`) take precedence over API keys stored via the interactive UI, so users can switch keys at launch time without touching stored configuration.
6+
7+
**Architecture:** In the provider loading sequence inside `provider.ts`, the `// load apikeys` section currently runs after `// load env` and overwrites whatever env var key was loaded. The fix adds a 2-line guard: before merging a stored API key into a provider, check whether that provider's env vars already provided a key. If so, skip the stored key.
8+
9+
**Tech Stack:** TypeScript, Effect, Bun test runner
10+
11+
## Global Constraints
12+
13+
- Run tests from `packages/opencode`, never from repo root
14+
- Use `bun test` (not `bun run test`) to run tests
15+
- Use `bun typecheck` (not `tsc`) for type checking
16+
- Do not move or restructure code blocks — only add the guard inside the existing loop
17+
18+
---
19+
20+
### Task 1: Write the failing test
21+
22+
**Files:**
23+
- Modify: `packages/opencode/test/provider/provider.test.ts` (append at end of file)
24+
25+
**Interfaces:**
26+
- Consumes: `Auth.Service` (already imported as `import { Auth } from "@/auth"`), `set` helper (already defined at line 36), `Global` from `@opencode-ai/core/global`, `Filesystem` from `@/util/filesystem`
27+
- Produces: test case `"env var takes precedence over stored API key"`
28+
29+
- [ ] **Step 1: Append the new test to the test file**
30+
31+
Open `packages/opencode/test/provider/provider.test.ts` and append at the very end:
32+
33+
```typescript
34+
it.instance("env var takes precedence over stored API key", () =>
35+
Effect.gen(function* () {
36+
// Set a stored API key for anthropic
37+
const auth = yield* Auth.Service
38+
yield* auth.set("anthropic", { type: "api", key: "stored-key" })
39+
40+
// Set an env var key — this should win
41+
yield* set("ANTHROPIC_API_KEY", "env-key")
42+
43+
const providers = yield* list
44+
const anthropic = providers[ProviderV2.ID.anthropic]
45+
expect(anthropic).toBeDefined()
46+
// The loaded key should be the env var, not the stored key
47+
expect(anthropic.key).toBe("env-key")
48+
}),
49+
)
50+
```
51+
52+
- [ ] **Step 2: Run the test to verify it fails**
53+
54+
```bash
55+
cd packages/opencode && bun test test/provider/provider.test.ts --test-name-pattern "env var takes precedence over stored API key"
56+
```
57+
58+
Expected: test **FAILS** — currently `anthropic.key` will be `"stored-key"` (stored key wins over env).
59+
60+
---
61+
62+
### Task 2: Implement the fix
63+
64+
**Files:**
65+
- Modify: `packages/opencode/src/provider/provider.ts:1501-1512`
66+
67+
**Interfaces:**
68+
- Consumes: `database` (already in scope — the provider registry with `env` arrays), `envs` (already in scope — loaded env vars map)
69+
- Produces: modified `// load apikeys` loop that skips stored keys when env var is already providing a key
70+
71+
- [ ] **Step 1: Locate the `// load apikeys` loop**
72+
73+
In `packages/opencode/src/provider/provider.ts`, find this block (around line 1501):
74+
75+
```typescript
76+
// load apikeys
77+
const auths = yield* auth.all().pipe(Effect.orDie)
78+
for (const [id, provider] of Object.entries(auths)) {
79+
const providerID = ProviderV2.ID.make(id)
80+
if (disabled.has(providerID)) continue
81+
if (provider.type === "api") {
82+
mergeProvider(providerID, {
83+
source: "api",
84+
key: provider.key,
85+
})
86+
}
87+
}
88+
```
89+
90+
- [ ] **Step 2: Add the env-priority guard**
91+
92+
Replace only the inner `if (provider.type === "api")` block:
93+
94+
```typescript
95+
// load apikeys
96+
const auths = yield* auth.all().pipe(Effect.orDie)
97+
for (const [id, provider] of Object.entries(auths)) {
98+
const providerID = ProviderV2.ID.make(id)
99+
if (disabled.has(providerID)) continue
100+
if (provider.type === "api") {
101+
const envKey = database[providerID]?.env.map((item) => envs[item]).find(Boolean)
102+
if (envKey) continue
103+
mergeProvider(providerID, {
104+
source: "api",
105+
key: provider.key,
106+
})
107+
}
108+
}
109+
```
110+
111+
The two new lines are:
112+
1. `const envKey = database[providerID]?.env.map((item) => envs[item]).find(Boolean)` — reuse the same env-var lookup already used in `// load env`
113+
2. `if (envKey) continue` — skip stored key when env var is present
114+
115+
- [ ] **Step 3: Run type check**
116+
117+
```bash
118+
cd packages/opencode && bun typecheck
119+
```
120+
121+
Expected: no errors.
122+
123+
- [ ] **Step 4: Run the new test to verify it passes**
124+
125+
```bash
126+
cd packages/opencode && bun test test/provider/provider.test.ts --test-name-pattern "env var takes precedence over stored API key"
127+
```
128+
129+
Expected: **PASS**
130+
131+
- [ ] **Step 5: Run the full provider test suite to check for regressions**
132+
133+
```bash
134+
cd packages/opencode && bun test test/provider/provider.test.ts
135+
```
136+
137+
Expected: all existing tests **PASS**.
138+
139+
- [ ] **Step 6: Commit**
140+
141+
```bash
142+
git add packages/opencode/src/provider/provider.ts packages/opencode/test/provider/provider.test.ts
143+
git commit -m "fix(core): env var api key takes precedence over stored key"
144+
```
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Design: Env Var API Key Takes Precedence Over UI-Stored Key
2+
3+
**Date**: 2026-07-07
4+
**Scope**: `packages/opencode/src/provider/provider.ts`
5+
6+
## Problem
7+
8+
When a user configures an API key through the interactive UI (stored in the auth database), that stored key currently **overrides** any `OPENCODE_API_KEY` environment variable set at launch time. This makes it impossible to switch keys via the command line without modifying stored configuration.
9+
10+
The root cause: in the provider loading sequence, `// load env` runs before `// load apikeys`. Because `mergeDeep` is applied sequentially, the later call (`// load apikeys`) overwrites the `key` field set by the earlier `// load env` call.
11+
12+
## Goal
13+
14+
When a provider's environment variable (e.g. `OPENCODE_API_KEY`) is set at launch time, it must take precedence over any stored API key configured through the UI. This lets users switch keys by setting the env var without touching stored configuration.
15+
16+
## Design
17+
18+
**File**: `packages/opencode/src/provider/provider.ts`
19+
**Location**: `// load apikeys` loop (around line 1502)
20+
21+
In the `// load apikeys` loop, before merging a stored API key into the provider, check whether any of that provider's declared environment variables are already set. If so, skip the stored key — the env var wins.
22+
23+
```typescript
24+
// load apikeys
25+
const auths = yield* auth.all().pipe(Effect.orDie)
26+
for (const [id, provider] of Object.entries(auths)) {
27+
const providerID = ProviderV2.ID.make(id)
28+
if (disabled.has(providerID)) continue
29+
if (provider.type === "api") {
30+
const envKey = database[providerID]?.env.map((item) => envs[item]).find(Boolean)
31+
if (envKey) continue // env var takes precedence over stored key
32+
mergeProvider(providerID, {
33+
source: "api",
34+
key: provider.key,
35+
})
36+
}
37+
}
38+
```
39+
40+
The `database[providerID]?.env` array is the same list of env var names already used in the `// load env` section (e.g. `["OPENCODE_API_KEY"]` for the opencode provider). Re-using this lookup ensures the two sections stay in sync.
41+
42+
## Scope
43+
44+
This change applies to **all providers**, not just opencode. The policy — "an explicitly set runtime env var overrides persisted configuration" — is a sound general principle and reduces special-case branching.
45+
46+
## Non-Goals
47+
48+
- Does not affect the `opencode.ts` plugin's `hasKey` check (line 166), which independently determines whether to operate in public mode. That logic already checks `process.env.OPENCODE_API_KEY` first and remains correct.
49+
- Does not change behavior when no env var is set — stored keys continue to work as before.
50+
- Does not affect OAuth credentials; only `provider.type === "api"` (stored API keys) is touched.
51+
52+
## Merge Conflict Risk
53+
54+
Low. The change is a 2-line addition inside an existing loop. It does not move or restructure any code blocks, minimizing diff size and conflict surface when merging upstream changes.

packages/opencode/src/provider/provider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,8 @@ const layer = Layer.effect(
15081508
const providerID = ProviderV2.ID.make(id)
15091509
if (disabled.has(providerID)) continue
15101510
if (provider.type === "api") {
1511+
const envKey = database[providerID]?.env.map((item) => envs[item]).find(Boolean)
1512+
if (envKey) continue
15111513
mergeProvider(providerID, {
15121514
source: "api",
15131515
key: provider.key,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Tests for env var API key priority over stored (UI-configured) keys.
3+
*
4+
* These tests live in a separate file to minimize merge conflicts with
5+
* the upstream provider.test.ts.
6+
*/
7+
import { afterEach, expect } from "bun:test"
8+
import { Effect } from "effect"
9+
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
10+
import { Auth } from "@/auth"
11+
import { Env } from "../../src/env"
12+
import { Plugin } from "../../src/plugin/index"
13+
import { Provider } from "@/provider/provider"
14+
import { testEffect } from "../lib/effect"
15+
import { ProviderV2 } from "@opencode-ai/core/provider"
16+
import { disposeAllInstances } from "../fixture/fixture"
17+
18+
const originalEnv = new Map<string, string | undefined>()
19+
20+
const rememberEnv = (k: string) => {
21+
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
22+
}
23+
24+
const set = (k: string, v: string) =>
25+
Effect.gen(function* () {
26+
rememberEnv(k)
27+
process.env[k] = v
28+
yield* Env.use.set(k, v)
29+
})
30+
31+
const remove = (k: string) =>
32+
Effect.gen(function* () {
33+
rememberEnv(k)
34+
delete process.env[k]
35+
yield* Env.use.remove(k)
36+
})
37+
38+
afterEach(async () => {
39+
for (const [key, value] of originalEnv) {
40+
if (value === undefined) delete process.env[key]
41+
else process.env[key] = value
42+
}
43+
originalEnv.clear()
44+
await disposeAllInstances()
45+
})
46+
47+
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, Auth.node])))
48+
49+
const list = Provider.use.list()
50+
51+
// ── Case 1: regression ─────────────────────────────────────────────────────
52+
// When no env var is set, a stored API key should still be used.
53+
it.instance("stored key is used when no env var is set", () =>
54+
Effect.gen(function* () {
55+
const auth = yield* Auth.Service
56+
yield* auth.set("anthropic", { type: "api", key: "stored-only-key" })
57+
yield* Effect.addFinalizer(() => auth.remove("anthropic").pipe(Effect.orDie))
58+
59+
yield* remove("ANTHROPIC_API_KEY")
60+
61+
const providers = yield* list
62+
const anthropic = providers[ProviderV2.ID.anthropic]
63+
expect(anthropic).toBeDefined()
64+
expect(anthropic.key).toBe("stored-only-key")
65+
expect(anthropic.source).toBe("api")
66+
}),
67+
)
68+
69+
// ── Case 2: opencode provider ───────────────────────────────────────────────
70+
// OPENCODE_API_KEY env var should take precedence over a stored opencode key.
71+
// This is the primary use-case: switch keys at launch without touching stored config.
72+
it.instance("OPENCODE_API_KEY env var takes precedence over stored opencode key", () =>
73+
Effect.gen(function* () {
74+
const auth = yield* Auth.Service
75+
yield* auth.set("opencode", { type: "api", key: "stored-opencode-key" })
76+
yield* Effect.addFinalizer(() => auth.remove("opencode").pipe(Effect.orDie))
77+
78+
yield* set("OPENCODE_API_KEY", "env-opencode-key")
79+
80+
const providers = yield* list
81+
const opencode = providers[ProviderV2.ID.opencode]
82+
expect(opencode).toBeDefined()
83+
expect(opencode.key).toBe("env-opencode-key")
84+
expect(opencode.source).toBe("env")
85+
}),
86+
)
87+
88+
// ── Case 3: independence across providers ──────────────────────────────────
89+
// An env var for one provider must not affect another provider's stored key.
90+
it.instance("env var for one provider does not affect another provider stored key", () =>
91+
Effect.gen(function* () {
92+
const auth = yield* Auth.Service
93+
// anthropic: env var set → should use env
94+
yield* set("ANTHROPIC_API_KEY", "env-anthropic-key")
95+
// openai: only stored key, no env var → should use stored
96+
yield* auth.set("openai", { type: "api", key: "stored-openai-key" })
97+
yield* Effect.addFinalizer(() => auth.remove("openai").pipe(Effect.orDie))
98+
yield* remove("OPENAI_API_KEY")
99+
100+
const providers = yield* list
101+
102+
const anthropic = providers[ProviderV2.ID.anthropic]
103+
expect(anthropic).toBeDefined()
104+
expect(anthropic.key).toBe("env-anthropic-key")
105+
expect(anthropic.source).toBe("env")
106+
107+
const openai = providers[ProviderV2.ID.openai]
108+
expect(openai).toBeDefined()
109+
expect(openai.key).toBe("stored-openai-key")
110+
expect(openai.source).toBe("api")
111+
}),
112+
)

packages/opencode/test/provider/provider.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ const paid = (providers: Record<string, { models: Record<string, { cost: { input
8484

8585
const languageBaseURL = (language: unknown) => (language as { config: { baseURL: string } }).config.baseURL
8686

87-
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node])))
87+
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, Auth.node])))
8888
const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true }))
8989

9090
const alphaProviderConfig = {
@@ -1910,3 +1910,21 @@ it.effect("opencode loader keeps paid models when auth exists", () =>
19101910
expect(keyedCount).toBeGreaterThan(0)
19111911
}).pipe(provideMultiInstance),
19121912
)
1913+
1914+
it.instance("env var takes precedence over stored API key", () =>
1915+
Effect.gen(function* () {
1916+
const auth = yield* Auth.Service
1917+
yield* auth.set("anthropic", { type: "api", key: "stored-key" })
1918+
yield* Effect.addFinalizer(() => auth.remove("anthropic").pipe(Effect.orDie))
1919+
1920+
// Set an env var key — this should win
1921+
yield* set("ANTHROPIC_API_KEY", "env-key")
1922+
1923+
const providers = yield* list
1924+
const anthropic = providers[ProviderV2.ID.anthropic]
1925+
expect(anthropic).toBeDefined()
1926+
// The loaded key should be the env var, not the stored key
1927+
expect(anthropic.key).toBe("env-key")
1928+
expect(anthropic.source).toBe("env")
1929+
}),
1930+
)

0 commit comments

Comments
 (0)