Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline e0a4e8b96c refactor(core): remove redundant model catalog die 2026-08-09 21:25:59 +00:00
Aiden Cline 0b3e0d80d0 fix(core): validate model catalog responses 2026-08-09 21:22:07 +00:00
Aiden Cline 5061a910e6 fix(core): tolerate model catalog fetch failure 2026-08-09 04:18:38 +00:00
opencode-agent[bot] 38e10eb140 fix(opencode): ignore unknown config fields (#41312) 2026-08-08 14:53:43 -04:00
4 changed files with 67 additions and 47 deletions
+12 -4
View File
@@ -131,6 +131,8 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
const Catalog = Schema.Record(Schema.String, Provider)
export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
@@ -182,6 +184,7 @@ const layer = Layer.effect(
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(Catalog)),
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
@@ -201,6 +204,7 @@ const layer = Layer.effect(
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Catalog))(text)
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
@@ -211,7 +215,7 @@ const layer = Layer.effect(
}),
),
)
return text
return catalog
})
const populate = Effect.gen(function* () {
@@ -221,14 +225,18 @@ const layer = Layer.effect(
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
return yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
)
return JSON.parse(text) as Record<string, Provider>
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
}).pipe(
Effect.withSpan("ModelsDev.populate"),
Effect.catch((error) =>
Effect.logError("Failed to fetch models.dev", { error }).pipe(Effect.as({} as Record<string, Provider>)),
),
)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
+45 -10
View File
@@ -112,6 +112,18 @@ const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.strin
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
eff.pipe(Effect.provide(buildLayer(state)))
const withFetch = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => effect,
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
beforeEach(async () => {
await rm(cacheFile, { force: true })
})
@@ -159,16 +171,7 @@ describe("ModelsDev Service", () => {
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state))
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
expect(result).toEqual(fixture2)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)
@@ -176,6 +179,38 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns an empty catalog when the initial fetch fails", () =>
Effect.gen(function* () {
const state = yield* Ref.make({ ...initialState, status: 503 })
const context = yield* Layer.build(buildLayer(state))
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
expect(result).toEqual({})
expect((yield* Ref.get(state)).calls.length).toBe(3)
}),
)
it.live("get() returns an empty catalog when the response is malformed JSON", () =>
Effect.gen(function* () {
const state = yield* Ref.make({ ...initialState, body: "{" })
const context = yield* Layer.build(buildLayer(state))
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
expect(result).toEqual({})
expect((yield* Ref.get(state)).calls.length).toBe(1)
expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false)
}),
)
it.live("get() returns an empty catalog when the response has an invalid shape", () =>
Effect.gen(function* () {
const state = yield* Ref.make({ ...initialState, body: JSON.stringify({ acme: {} }) })
const context = yield* Layer.build(buildLayer(state))
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
expect(result).toEqual({})
expect((yield* Ref.get(state)).calls.length).toBe(1)
expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false)
}),
)
it.live("get() is single-flight under concurrent calls", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
+5 -23
View File
@@ -37,22 +37,11 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
const extra = topLevelExtraKeys(schema, data)
if (extra.length) {
throw new InvalidError({
path: source,
issues: [
{
code: "unrecognized_keys",
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
},
],
})
}
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
const decoded = EffectSchema.decodeUnknownExit(schema)(data, {
errors: "all",
onExcessProperty: "ignore",
propertyOrder: "original",
})
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause)
@@ -70,10 +59,3 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
{ cause: error },
)
}
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return []
const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name)))
return Object.keys(data).filter((key) => !known.has(key))
}
+5 -10
View File
@@ -597,12 +597,12 @@ accountTokenIt.instance("resolves env templates in account config with account t
}),
)
it.instance("validates config schema and throws on invalid fields", () =>
it.instance("validates config schema and throws on invalid values", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
model: 42,
})
const exit = yield* Config.use.get().pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@@ -1331,7 +1331,7 @@ it.instance("permission config preserves user key order", () =>
}),
)
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
test("config parser preserves permission order while ignoring unknown top-level keys", () => {
const config = ConfigParse.schema(
ConfigV1.Info,
{
@@ -1340,18 +1340,13 @@ test("config parser preserves permission order while rejecting unknown top-level
"*": "deny",
edit: "ask",
},
plugins: ["example"],
},
"test",
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
try {
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
}
expect(config).not.toHaveProperty("plugins")
})
// MCP config merging tests