Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
"packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab",
"packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8",
"packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f",
"packages/opencode/src/provider/provider.ts": "435a2838291d2aa32de4d1ecab1778b443f9ec9b3b5ed409b94d3b2de0142853",
"packages/opencode/src/provider/provider.ts": "b89bc0ca0a12ea0cfaf5c956a4b731a0397d4c407e787a16cdd66fb7f54ee476",
"packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8",
"packages/opencode/src/question/index.ts": "8317348c2ca27dd9e4f7d18fdc126a4d355c20495c1b86668430ad477a68e9b3",
"packages/opencode/src/server/auth.ts": "a054aff2ea06a303a99f326275332a68089c58916356a26d66af44f048f597cc",
Expand Down Expand Up @@ -405,7 +405,7 @@
"packages/opencode/test/project/worktree-rename.test.ts": "3ec168c9f8595249b75832895a251483ec1f3641ce44ad0bd5a664b5bbd6dad1",
"packages/opencode/test/provider/combine-abort-signals.test.ts": "0a997303e717184e3e237e4b978b64a34ab8022724c47245b25f32e90738d205",
"packages/opencode/test/provider/harmoniqs-tools.test.ts": "0c626daa51c312eb81dd854509a2e1944712ee9620a36eb4db66078ab80d6b82",
"packages/opencode/test/provider/provider.test.ts": "5b2cbed8994d3bbc968a72d01459460c1de72b6117a0795c2bc6a10abbf02926",
"packages/opencode/test/provider/provider.test.ts": "37ed7d4dbf68da214a96cb66dacda71c6e82498382fcd28c3272ec4d33462b20",
"packages/opencode/test/provider/transform.test.ts": "ecf9dd1bdec36c2126f3140d43125728c06211b9771d47619ff65f7453ff37fd",
"packages/opencode/test/question/question-store.test.ts": "9186b84cb1b390f47e8e8c30b6e2448ebe5096335aee38fe4c12c897d27d2150",
"packages/opencode/test/server/amicode-connections-routes.test.ts": "de8ddeaabe23e20a0dc33eef6c70232ba20cca0301cbea08fe9fd751a07d6649",
Expand Down Expand Up @@ -1011,7 +1011,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-10-22T13:11:47.632Z",
"extracted_at": "2026-10-23T12:21:18.499Z",
"per_package": {
"packages/app": {
"A": 254,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,7 @@ interface State {
sdk: Map<string, BundledSDK>
modelLoaders: Record<string, CustomModelLoader>
varsLoaders: Record<string, CustomVarsLoader>
authStamp: string | undefined
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
Expand Down Expand Up @@ -1476,8 +1477,24 @@ const layer = Layer.effect(
const modelsDevSvc = yield* ModelsDev.Service
const runtimeFlags = yield* RuntimeFlags.Service

// #1848: the stored-credential fingerprint the provider state was built
// from. auth.json is shared by every engine process on the host (the
// fleet's shards), but the provider state is cached per instance per
// process — a connect landing on one shard left the others serving the
// pre-connect provider list until restart. Every state read compares the
// on-disk fingerprint and rebuilds on drift, so an auth write from ANY
// process (or the CLI) reaches every engine without an instance dispose
// (which would cancel the instance's running sessions).
const authStamp = auth.all().pipe(
Effect.map((all) =>
Hash.fast(JSON.stringify(Object.keys(all).sort().map((key) => [key, all[key]]))),
),
Effect.orElseSucceed(() => undefined),
)

const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () {
const builtFrom = yield* authStamp
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const modelsDev = yield* modelsDevSvc.get()
Expand Down Expand Up @@ -1804,11 +1821,25 @@ const layer = Layer.effect(
sdk,
modelLoaders,
varsLoaders,
authStamp: builtFrom,
}
}),
)

const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
// #1848: the state, rebuilt first when stored credentials changed on disk
// since it was built. An unreadable auth file never forces a rebuild.
const current = Effect.fn("Provider.current")(function* () {
const s = yield* InstanceState.get(state)
const stamp = yield* authStamp
if (stamp === undefined || stamp === s.authStamp) return s
yield* Effect.logInfo("stored credentials changed on disk; rebuilding provider state")
yield* InstanceState.invalidate(state)
return yield* InstanceState.get(state)
})

const list = Effect.fn("Provider.list")(function* () {
return (yield* current()).providers
})

async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try {
Expand Down Expand Up @@ -1958,12 +1989,12 @@ const layer = Layer.effect(
}
}

const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getProvider = Effect.fn("Provider.getProvider")(function* (providerID: ProviderV2.ID) {
return (yield* current()).providers[providerID]
})

const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ModelV2.ID) {
const s = yield* InstanceState.get(state)
const s = yield* current()
const provider = s.providers[providerID]
if (!provider) {
const catalogProvider = s.catalog[providerID]
Expand All @@ -1987,7 +2018,7 @@ const layer = Layer.effect(
})

const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
const s = yield* InstanceState.get(state)
const s = yield* current()
const envs = yield* env.all()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
Expand Down Expand Up @@ -2018,7 +2049,7 @@ const layer = Layer.effect(
})

const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) {
const s = yield* InstanceState.get(state)
const s = yield* current()
const provider = s.providers[providerID]
if (!provider) return undefined
for (const item of query) {
Expand All @@ -2039,7 +2070,7 @@ const layer = Layer.effect(
)
}

const s = yield* InstanceState.get(state)
const s = yield* current()
const provider = s.providers[providerID]
if (!provider) return undefined

Expand Down Expand Up @@ -2100,7 +2131,7 @@ const layer = Layer.effect(

const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
const cfg = yield* config.get()
const s = yield* InstanceState.get(state)
const s = yield* current()

if (cfg.model) {
const parsed = parseModel(cfg.model)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2077,3 +2077,40 @@ it.effect("opencode loader keeps paid models when auth exists", () =>
expect(keyedCount).toBeGreaterThan(0)
}).pipe(provideMultiInstance),
)

// #1848: the fleet's engine shards share one auth.json but each caches its
// provider state per instance. A connect landing on ANOTHER process (a sibling
// shard, the CLI) writes the file directly; this instance must see the new
// provider — and lose a removed one — on its next read, with no dispose.
it.instance("stored credentials written by another process reach a live instance", () =>
Effect.gen(function* () {
yield* remove("GROQ_API_KEY")
const groq = ProviderV2.ID.make("groq")
const authPath = path.join(Global.Path.data, "auth.json")
const original = yield* Effect.promise(() => Filesystem.readText(authPath).catch(() => undefined))
const base = original ? (JSON.parse(original) as Record<string, unknown>) : {}
const { groq: _ignored, ...without } = base
const writeAuth = (data: Record<string, unknown>) =>
Effect.promise(() => Filesystem.write(authPath, JSON.stringify(data)))

yield* Effect.acquireRelease(writeAuth(without), () =>
Effect.promise(async () => {
if (original !== undefined) await Filesystem.write(authPath, original)
else await unlink(authPath).catch(() => undefined)
}),
)

// the state is built (and cached) before the "other shard" connects
expect((yield* list)[groq]).toBeUndefined()

yield* writeAuth({ ...without, groq: { type: "api", key: "sibling-shard-key" } })
const connected = yield* list
expect(connected[groq]).toBeDefined()
expect(connected[groq].source).toBe("api")
expect(yield* Provider.Service.use((svc) => svc.getProvider(groq))).toBeDefined()

// and a disconnect on the sibling drops it here too (the stale-openrouter half)
yield* writeAuth(without)
expect((yield* list)[groq]).toBeUndefined()
}),
)
Loading