Skip to content

Commit 05affb9

Browse files
authored
feat: auth-aware source model resolution (#348)
* docs(plans): add auth-aware source model resolution plan Plan for expanding SOURCE_CATEGORY_MODEL_DEFAULTS from a single provider/model string per category to an ordered array, resolved at plugin config(cfg) time by reading OpenCode's auth.json and selecting the first array entry whose provider is authenticated. 4 implementation units: 1. Expand source-default shape and update validators 2. Add auth-file reader and auth-aware resolver 3. Wire the auth-aware resolver into the config hook 4. Document the new behavior Origin: docs/brainstorms/2026-05-09-auth-aware-source-model-resolution-requirements.md (gitignored, local-only per project convention). Document review: 3 reviewers (coherence, feasibility, security-lens), 10 findings, all addressed before commit. Confidence check passed without deepening — local patterns abundant, brainstorm research exhaustive. * feat(agent-overlays): expand source category defaults to ordered arrays Change SOURCE_CATEGORY_MODEL_DEFAULTS shape from Record<CategoryId, string> to Record<CategoryId, readonly string[]>. Each array is an ordered preference list, most preferred first. This commit is shape-only — getSourceCategoryModel(category) still returns the first array entry, preserving current zero-config behavior. The auth-aware resolution that picks based on which provider is authenticated lands in a follow-up commit. validateSourceCategoryModelDefaults now rejects non-arrays and empty arrays explicitly, and iterates each entry with indexed key paths (source category model defaults.${category}[${index}]) for clearer error messages. * feat(agent-overlays): add auth-aware source model resolver Add getAuthenticatedProviders() that reads OpenCode's auth.json synchronously using the XDG_DATA_HOME path convention. Returns the top-level keys as a ReadonlySet<string>. Treats missing files as 'no providers authenticated' silently; emits a single stderr diagnostic for unreadable or malformed files without leaking contents. Reads only Object.keys; nested values (API keys, OAuth tokens) are never inspected, logged, persisted, or transmitted. Extend getSourceCategoryModel(category, authedProviders?) with an optional second parameter. When provided, the resolver returns the first array entry whose provider ID (substring before the first /) is in the authenticated set. Falls back to array[0] when no entry matches or when no auth set is supplied (backward compat). Tests: 15 new scenarios including a real-token leak fixture that captures stderr/stdout and asserts no secret-shaped strings appear, and a file-not-modified contract verified via mtime + sha256. * feat(config-handler): wire auth-aware resolver into config hook Read OpenCode's authenticated providers once per config(cfg) hook invocation in createConfigHandler, thread the ReadonlySet through collectAgents and applyAgentOverlays, and pass it to getSourceCategoryModel(category, authedProviders) so source defaults pick the first array entry whose provider is authenticated. Overlay precedence is unchanged: source default -> category overlay -> exact overlay. Auth-aware resolution only affects the source default; user/category/exact overlays still win as today. Add ConfigHandlerDeps.getAuthenticatedProviders for test injection. Tests: - 6 new unit tests in tests/unit/config-handler.test.ts covering no-auth zero-config, single-provider match, multi-provider first-match-wins, category-overlay-wins, exact-overlay-wins, and the read-once contract via injected counting spy. - 2 new integration scenarios in tests/integration/opencode.test.ts using the existing homeDir fixture: single-provider auth and multi-provider auth, asserting emitted models match per-category expectations. * docs: explain auth-aware source model resolution Document that SOURCE_CATEGORY_MODEL_DEFAULTS is now an ordered preference array per category and that the resolver reads OpenCode's auth.json at config(cfg) time to pick the first array entry whose provider is authenticated. Falls back to the first array entry when no match is found. Make the resolution precedence explicit: user agents.<key>.model > user categories.<id>.model > source-default-resolver > bundled markdown > OpenCode inheritance. User overlays remain scalar provider/model strings; arrays are not accepted in user config in this iteration. Document two known limitations: (1) autoload-true providers like AWS Bedrock that load from environment variables may not appear in auth.json and may be skipped by the resolver — pin via category or exact overlay; (2) a tiny race window exists with 'opencode auth login' that resolves on next OpenCode restart. Preserve the existing 'Systematic does not support fallback_models' sentence — the arrays are a preference list, not a runtime fallback chain. * Address PR review feedback (#348) - Fix CodeQL js/file-system-race in tests/unit/agent-overlays.test.ts by removing the redundant second fs.readFileSync; mtime + size comparison is sufficient to prove the file wasn't touched. - Narrow isSystemError type assertion from Record<string, unknown> to { code: unknown } per Fro Bot NBC. - Replace the nested-form provider-extraction test with a focused public-API assertion of the slashIndex logic, naming the false-match guarantee in the test description. - Add 3 XDG_DATA_HOME branch tests covering absolute path, empty fallback, and non-absolute fallback. - Mark plan units 1-4 as completed and flip plan status to completed. Net: +3 tests, 562 unit tests pass.
1 parent 6e14d5d commit 05affb9

8 files changed

Lines changed: 1058 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ Systematic separates config-source precedence from overlay precedence. Config fi
338338

339339
Source category model defaults are primary model choices only — they are not fallback chains. Systematic does not support `fallback_models`, inherited retry semantics, runtime fallback behavior, or fallback to the parent model when a source model is unavailable. Explicit and source model IDs are structurally validated and may still fail at OpenCode runtime if the provider or model is unavailable.
340340

341+
Source category model defaults are now ordered preference arrays per category rather than single strings. At plugin load, Systematic reads OpenCode's authentication state from `auth.json` and selects the first array entry whose provider is authenticated. For example, the `review` category defaults to `['anthropic/claude-opus-4.7', 'openai/gpt-5.5']` — a user authenticated only to OpenAI receives `openai/gpt-5.5` (first match), while a user authenticated to Anthropic (or both) receives the more preferred `anthropic/claude-opus-4.7`. If no array entry's provider is authenticated, the first entry is used as the default. The arrays are an ordered preference list, not a runtime fallback chain — `fallback_models` is still not supported.
342+
341343
If you want to restore OpenCode parent-model inheritance for a bundled agent or category (opting out of the source default), set `"model": null` in high-trust user or `$OPENCODE_CONFIG_DIR/systematic.json` config. Project config cannot use `model: null` — project config cannot set, erase, or shadow `model` at any value.
342344

343345
The source defaults are:

docs/plans/2026-05-09-004-feat-auth-aware-source-model-resolution-plan.md

Lines changed: 343 additions & 0 deletions
Large diffs are not rendered by default.

docs/src/content/docs/getting-started/configuration.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,40 @@ This only works from high-trust config (user or `$OPENCODE_CONFIG_DIR/systematic
119119

120120
Native OpenCode agents with the same emitted key are full replacements. If `config.agent.security-sentinel` already exists in OpenCode config, `agents.security-sentinel` or `agents.review/security-sentinel` conflicts because ownership would be ambiguous. Category overlays skip native replacements and continue applying to the remaining Systematic-owned bundled agents in that category.
121121

122+
#### Auth-Aware Resolution
123+
124+
Source category model defaults are now ordered preference arrays per category rather than single strings. At plugin load, Systematic reads OpenCode's authentication state and selects the first array entry whose provider is authenticated.
125+
126+
```jsonc
127+
// The built-in defaults for each category (code-owned, not user-configurable).
128+
// The resolver picks the first entry whose provider is in the auth set.
129+
{
130+
"design": ["openai/gpt-5.5", "anthropic/claude-opus-4.7"],
131+
"docs": ["openai/gpt-5.4-mini", "anthropic/claude-haiku-4-5"],
132+
"document-review": ["anthropic/claude-opus-4.7", "openai/gpt-5.5"],
133+
"research": ["openai/gpt-5.5", "anthropic/claude-opus-4.7"],
134+
"review": ["anthropic/claude-opus-4.7", "openai/gpt-5.5"],
135+
"workflow": ["openai/gpt-5.4-mini", "anthropic/claude-haiku-4-5"]
136+
}
137+
```
138+
139+
The authentication file is read from `${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json`. Systematic reads only the top-level keys of this file — it never inspects, logs, persists, or transmits the nested credential values.
140+
141+
**Precedence:** The auth-aware resolver only applies when no stronger user override exists. Full precedence for a bundled agent's model:
142+
143+
1. `agents.<key>.model` (user or `$OPENCODE_CONFIG_DIR/systematic.json`)
144+
2. `categories.<id>.model` (user or `$OPENCODE_CONFIG_DIR/systematic.json`)
145+
3. Source category model default resolved against authenticated providers
146+
4. Bundled markdown/frontmatter defaults (unused — bundled agents omit `model`)
147+
5. OpenCode inherited defaults
148+
149+
User-supplied `agents.<key>.model` and `categories.<id>.model` remain single `provider/model` strings in this iteration — arrays are not accepted in user config. If you supply a scalar model string, the auth-aware resolver is skipped for that agent or category and your overlay is used as-is.
150+
151+
**Limitations:**
152+
153+
- **Environment-variable providers.** Providers that load credentials from environment variables (e.g., AWS Bedrock, with `autoload: true` in OpenCode's provider registry) may not appear in `auth.json`. The resolver skips them and falls back to the first array entry. To override, pin a model via `categories.<id>.model` or `agents.<key>.model`.
154+
- **Auth-file race window.** OpenCode writes `auth.json` non-atomically; the resolver reads it once at plugin load. If you authenticate after starting OpenCode, restart OpenCode to refresh the resolved model.
155+
122156
## Project-Specific Content
123157

124158
One of Systematic's most powerful features is the ability to add your own skills and agents that live alongside (or override) the bundled ones.

src/lib/agent-overlays.ts

Lines changed: 116 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import os from 'node:os'
23
import path from 'node:path'
34
import type { SourcedOverlayConfigMap } from './config.js'
45
import { isAgentMode, isPermissionSetting, isRecord } from './validation.js'
@@ -49,14 +50,17 @@ export interface ResolvedAgentOverlaySet {
4950
categoriesByKey: Map<string, ValidatedCategoryOverlay>
5051
}
5152

53+
// Ordered preference lists, most preferred first. The resolver picks the
54+
// first array entry whose provider is authenticated; entries are not a
55+
// runtime fallback chain. Keep arrays non-empty.
5256
const SOURCE_CATEGORY_MODEL_DEFAULTS = {
53-
design: 'openai/gpt-5.5',
54-
docs: 'openai/gpt-5.4-mini',
55-
'document-review': 'anthropic/claude-opus-4.7',
56-
research: 'openai/gpt-5.5',
57-
review: 'anthropic/claude-opus-4.7',
58-
workflow: 'openai/gpt-5.4-mini',
59-
} as const satisfies Record<string, string>
57+
design: ['openai/gpt-5.5', 'anthropic/claude-opus-4.7'],
58+
docs: ['openai/gpt-5.4-mini', 'anthropic/claude-haiku-4-5'],
59+
'document-review': ['anthropic/claude-opus-4.7', 'openai/gpt-5.5'],
60+
research: ['openai/gpt-5.5', 'anthropic/claude-opus-4.7'],
61+
review: ['anthropic/claude-opus-4.7', 'openai/gpt-5.5'],
62+
workflow: ['openai/gpt-5.4-mini', 'anthropic/claude-haiku-4-5'],
63+
} as const satisfies Record<string, readonly string[]>
6064

6165
const ALLOWED_OVERLAY_FIELDS = new Set([
6266
'model',
@@ -178,13 +182,88 @@ export function inferBuiltInTemperature(
178182
return 0.3
179183
}
180184

185+
/**
186+
* Read which providers are authenticated from OpenCode's auth.json.
187+
*
188+
* Reads only top-level keys (provider IDs). Nested values are NEVER
189+
* inspected, logged, persisted, or transmitted. This is a hard contract:
190+
* the auth file holds API keys and OAuth tokens, and Systematic must
191+
* never expose them via stderr, telemetry, or any other channel.
192+
*
193+
* Intended for one invocation per plugin config(cfg) cycle. Repeated
194+
* calls trigger repeated file reads and, on malformed input, repeated
195+
* stderr diagnostics.
196+
*
197+
* @param rootDirOverride - Optional path override for tests. When
198+
* non-empty, the auth file is resolved as
199+
* `path.join(rootDirOverride, 'opencode', 'auth.json')`. When
200+
* omitted, resolution follows XDG_DATA_HOME -> ~/.local/share
201+
* convention.
202+
* @returns A readonly set of authenticated provider IDs (empty set on
203+
* any failure).
204+
*/
205+
export function getAuthenticatedProviders(
206+
rootDirOverride?: string,
207+
): ReadonlySet<string> {
208+
const xdgDataHome = process.env.XDG_DATA_HOME?.trim()
209+
const rootDir =
210+
rootDirOverride ||
211+
(xdgDataHome && path.isAbsolute(xdgDataHome)
212+
? xdgDataHome
213+
: path.join(os.homedir(), '.local/share'))
214+
const authPath = path.join(rootDir, 'opencode', 'auth.json')
215+
216+
let raw: string
217+
try {
218+
raw = fs.readFileSync(authPath, 'utf8')
219+
} catch (err: unknown) {
220+
if (isSystemError(err) && err.code === 'ENOENT') {
221+
return new Set()
222+
}
223+
console.warn(`[systematic] auth.json unreadable at ${authPath}; ignoring`)
224+
return new Set()
225+
}
226+
227+
let parsed: unknown
228+
try {
229+
parsed = JSON.parse(raw)
230+
} catch {
231+
console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`)
232+
return new Set()
233+
}
234+
235+
if (!isRecord(parsed)) {
236+
console.warn(`[systematic] auth.json malformed at ${authPath}; ignoring`)
237+
return new Set()
238+
}
239+
240+
return new Set(Object.keys(parsed))
241+
}
242+
181243
export function getSourceCategoryModel(
182244
category: string | undefined,
245+
authedProviders?: ReadonlySet<string>,
183246
): string | undefined {
184247
if (!category) return undefined
185-
return (SOURCE_CATEGORY_MODEL_DEFAULTS as Record<string, string | undefined>)[
186-
category
187-
]
248+
const candidates = (
249+
SOURCE_CATEGORY_MODEL_DEFAULTS as Record<
250+
string,
251+
readonly string[] | undefined
252+
>
253+
)[category]
254+
if (!candidates || candidates.length === 0) return undefined
255+
if (!authedProviders || authedProviders.size === 0) return candidates[0]
256+
257+
for (const entry of candidates) {
258+
const slashIndex = entry.indexOf('/')
259+
if (slashIndex <= 0) continue
260+
const providerId = entry.slice(0, slashIndex)
261+
if (authedProviders.has(providerId)) {
262+
return entry
263+
}
264+
}
265+
266+
return candidates[0]
188267
}
189268

190269
export function assertSourceCategoryModelCoverage(categories: string[]): void {
@@ -204,12 +283,24 @@ export function assertSourceCategoryModelCoverage(categories: string[]): void {
204283
export function validateSourceCategoryModelDefaults(
205284
defaults: Record<string, unknown> = SOURCE_CATEGORY_MODEL_DEFAULTS,
206285
): void {
207-
for (const [category, model] of Object.entries(defaults)) {
208-
validateModel(
209-
'source category model defaults',
210-
`source category model defaults.${category}`,
211-
model,
212-
)
286+
for (const [category, value] of Object.entries(defaults)) {
287+
if (!Array.isArray(value)) {
288+
throw new Error(
289+
`Source category model defaults: ${category} must be a non-empty array of provider/model strings`,
290+
)
291+
}
292+
if (value.length === 0) {
293+
throw new Error(
294+
`Source category model defaults: ${category} must be a non-empty array of provider/model strings`,
295+
)
296+
}
297+
for (const [index, model] of value.entries()) {
298+
validateModel(
299+
'source category model defaults',
300+
`source category model defaults.${category}[${index}]`,
301+
model,
302+
)
303+
}
213304
}
214305
}
215306

@@ -637,3 +728,12 @@ function throwConfigError(
637728
`Invalid Systematic config in ${sourcePath}: ${keyPath} ${message}`,
638729
)
639730
}
731+
732+
function isSystemError(err: unknown): err is { code: string } {
733+
return (
734+
typeof err === 'object' &&
735+
err !== null &&
736+
'code' in err &&
737+
typeof (err as { code: unknown }).code === 'string'
738+
)
739+
}

src/lib/config-handler.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AgentConfig } from '@opencode-ai/sdk'
33
import {
44
assertSourceCategoryModelCoverage,
55
buildBundledAgentInventory,
6+
getAuthenticatedProviders,
67
getSourceCategoryModel,
78
inferBuiltInTemperature,
89
type ResolvedAgentOverlaySet,
@@ -23,6 +24,8 @@ export interface ConfigHandlerDeps {
2324
bundledSkillsDir: string
2425
bundledAgentsDir: string
2526
bundledCommandsDir: string
27+
/** Override for authenticated provider reader; for testing. */
28+
getAuthenticatedProviders?: (rootDirOverride?: string) => ReadonlySet<string>
2629
}
2730

2831
type CommandConfig = NonNullable<Config['command']>[string]
@@ -157,6 +160,7 @@ function collectAgents(
157160
disabledAgents: string[],
158161
nativeAgents: Record<string, unknown>,
159162
overlays: ResolvedAgentOverlaySet,
163+
authedProviders: ReadonlySet<string>,
160164
): NonNullable<Config['agent']> {
161165
const agents: NonNullable<Config['agent']> = {}
162166
const agentList = findAgentsInDir(dir)
@@ -174,7 +178,12 @@ function collectAgents(
174178

175179
const config = loadAgentAsConfig(agentInfo)
176180
if (config) {
177-
agents[agentInfo.name] = applyAgentOverlays(config, agentInfo, overlays)
181+
agents[agentInfo.name] = applyAgentOverlays(
182+
config,
183+
agentInfo,
184+
overlays,
185+
authedProviders,
186+
)
178187
}
179188
}
180189

@@ -185,6 +194,7 @@ function applyAgentOverlays(
185194
config: AgentConfig,
186195
agentInfo: { name: string; category?: string },
187196
overlays: ResolvedAgentOverlaySet,
197+
authedProviders: ReadonlySet<string>,
188198
): AgentConfig {
189199
const id = agentInfo.category
190200
? `${agentInfo.category}/${agentInfo.name}`
@@ -213,7 +223,10 @@ function applyAgentOverlays(
213223
// high-trust overlays apply. Precedence: exact overlay > category overlay
214224
// > source model default > markdown / inheritance.
215225
if (agentInfo.category) {
216-
const sourceModel = getSourceCategoryModel(agentInfo.category)
226+
const sourceModel = getSourceCategoryModel(
227+
agentInfo.category,
228+
authedProviders,
229+
)
217230
if (sourceModel) {
218231
result.model = sourceModel
219232
}
@@ -421,6 +434,8 @@ function collectEnabledSkillNames(
421434
export function createConfigHandler(deps: ConfigHandlerDeps) {
422435
const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } =
423436
deps
437+
const readAuthProviders =
438+
deps.getAuthenticatedProviders ?? getAuthenticatedProviders
424439

425440
return async (config: Config): Promise<void> => {
426441
const { config: systematicConfig, overlays } =
@@ -449,11 +464,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
449464
})
450465
const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays)
451466

467+
const authedProviders = readAuthProviders()
452468
const bundledAgents = collectAgents(
453469
bundledAgentsDir,
454470
systematicConfig.disabled_agents,
455471
existingAgents,
456472
resolvedOverlays,
473+
authedProviders,
457474
)
458475

459476
const bundledCommands = collectCommands(

tests/integration/opencode.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ describe('SystematicPlugin config hook integration', () => {
128128
os.homedir = originalHomedir
129129
_resetPluginSingleton()
130130
delete process.env.OPENCODE_CONFIG_DIR
131+
delete process.env.XDG_DATA_HOME
131132
fs.rmSync(tempDir, { recursive: true, force: true })
132133
})
133134

@@ -427,6 +428,49 @@ describe('SystematicPlugin config hook integration', () => {
427428
'nonexistent-provider/nonexistent-model',
428429
)
429430
})
431+
432+
test('auth-aware: single auth provider selects matching model from source defaults', async () => {
433+
const authDir = path.join(homeDir, '.local/share', 'opencode')
434+
fs.mkdirSync(authDir, { recursive: true })
435+
fs.writeFileSync(
436+
path.join(authDir, 'auth.json'),
437+
JSON.stringify({ openai: { type: 'api', key: 'sk-test' } }),
438+
)
439+
440+
const config: Config = {}
441+
await runConfigHook(config)
442+
443+
// review category: ['anthropic/claude-opus-4.7', 'openai/gpt-5.5']
444+
// With openai auth, resolver picks openai/gpt-5.5
445+
expect(config.agent?.['correctness-reviewer']?.model).toBe('openai/gpt-5.5')
446+
})
447+
448+
test('auth-aware: multi-provider auth picks correct models per category', async () => {
449+
const authDir = path.join(homeDir, '.local/share', 'opencode')
450+
fs.mkdirSync(authDir, { recursive: true })
451+
fs.writeFileSync(
452+
path.join(authDir, 'auth.json'),
453+
JSON.stringify({
454+
'github-copilot': { type: 'api' },
455+
anthropic: { type: 'api' },
456+
}),
457+
)
458+
459+
const config: Config = {}
460+
await runConfigHook(config)
461+
462+
// review category: ['anthropic/claude-opus-4.7', 'openai/gpt-5.5']
463+
// With anthropic authed, picks anthropic/claude-opus-4.7 (first match)
464+
expect(config.agent?.['correctness-reviewer']?.model).toBe(
465+
'anthropic/claude-opus-4.7',
466+
)
467+
468+
// docs category: ['openai/gpt-5.4-mini', 'anthropic/claude-haiku-4-5']
469+
// openai not authed, anthropic is → picks anthropic/claude-haiku-4-5
470+
expect(config.agent?.['ankane-readme-writer']?.model).toBe(
471+
'anthropic/claude-haiku-4-5',
472+
)
473+
})
430474
})
431475

432476
describe.skipIf(!OPENCODE_AVAILABLE)('opencode integration', () => {

0 commit comments

Comments
 (0)