Compare commits

...
22 changed files with 1147 additions and 212 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
+9 -9
View File
@@ -125,7 +125,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
@@ -364,7 +364,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -2156,19 +2156,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.9", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.9", "@opencode-ai/pty-darwin-x64": "0.1.9", "@opencode-ai/pty-linux-arm64-gnu": "0.1.9", "@opencode-ai/pty-linux-arm64-musl": "0.1.9", "@opencode-ai/pty-linux-x64-gnu": "0.1.9", "@opencode-ai/pty-linux-x64-musl": "0.1.9" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-9WysQgX9J3RXfZy/t/8MGqf1IGLeckyHQsT/eVnKiBnp+GnzOXtZePryj08CN/3GT2BuP5tWqMRMH0JzCMsrgg=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-a2OZGutBdVGDO+X4t37L2K8wD1phTcLJBcNLj8j3LqCmaHbJmQyNYYFo6i8loZCTjjhb+8Wq0wMxSLny4tqdwA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-+vyRLwzNMzP/JFtYEIkMHxRC9Lkd7sUeuAqI5medtdgZmvTwS3NdkHbq6zQQG3rTQ9asObPKilvcpQDf+LYHRQ=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vjNKhCsw6mI6w+9bITCyCmb5nS+XiWA8xMGn02GOKE9AahcVX9JIbjUF1zAmPXZ1QD7nxWoanh4FvnqukbYPHw=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-xpS0N6/uEiJPabv6Ib9BpOlfyZdcUES7sMVa4bCrgcxy6y4bnQMeGF/Ju4u7LBABD/rjBKZM0XhPMnvZ6qoX1w=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-N1Dx8rOLkpJd2DSetZZW9dPnpL3mxbRQagk/7K7TbS7M1rDnLazhq8/T9vulLET8KTwu6lmWcZYuSDfPLNWXnA=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-osb203LrlwXpQKQABrCfD5QRVK9Ajiq5atulC/VuMye6KkErcetDnjQvobjhaEjgnSEGPCH4U9sM84okGqip5Q=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
+23 -21
View File
@@ -346,7 +346,8 @@ export const Event = Schema.StructWithRest(
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
// OutputItemAdded/Done permit a null item in the Open Responses OpenAPI schema.
item: optionalNull(StreamItem),
response: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
@@ -812,7 +813,7 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
@@ -891,7 +892,7 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id) {
if (item?.type === "message" && item.id !== undefined) {
const phase = messagePhase(item.phase)
return [
{
@@ -922,7 +923,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -941,7 +942,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
if (event.summary_index === 0) return [state, NO_EVENTS]
@@ -988,7 +989,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
}
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
return [
@@ -1013,7 +1014,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
state: ParserState,
event: Event,
) {
if (!event.item_id) return [state, NO_EVENTS] satisfies StepResult
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
const tool = state.tools[event.item_id]
if (!tool) return [state, NO_EVENTS] satisfies StepResult
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
@@ -1044,7 +1045,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
if (item.type === "message" && item.id !== undefined) {
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
@@ -1075,7 +1076,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const result =
item.arguments === undefined
@@ -1136,7 +1137,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
!id ||
id === undefined ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
@@ -1219,12 +1220,13 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id && outputItemID(state, input) !== input.item_id
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
event.type === "response.output_text.delta"
? onOutputTextDelta(state, event, event.item_id)
@@ -1233,7 +1235,7 @@ export const step = (state: ParserState, input: Event) => {
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string")
if (event.item_id === undefined || typeof value !== "string")
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
@@ -1242,7 +1244,7 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
@@ -1250,24 +1252,24 @@ export const step = (state: ParserState, input: Event) => {
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_summary_part.done")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
@@ -1275,11 +1277,11 @@ export const step = (state: ParserState, input: Event) => {
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && !event.item.id)
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event)
}
@@ -184,7 +184,7 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
return event.item_id !== undefined
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
@@ -307,6 +307,287 @@ describe("Open Responses-compatible route", () => {
}),
)
describe("stream validation", () => {
const request = LLM.request({
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
prompt: "Respond.",
})
const fixtures = [
{
item: { type: "message" },
events: [
{ type: "response.output_text.delta", delta: "Preserved" },
{ type: "response.output_text.done", text: "Preserved" },
{ type: "response.refusal.delta", delta: "Preserved" },
{ type: "response.refusal.done", refusal: "Preserved" },
],
},
{
item: { type: "reasoning", encrypted_content: "encrypted-state" },
events: [
{ type: "response.reasoning.delta", delta: "Preserved" },
{ type: "response.reasoning.done", text: "Preserved" },
{ type: "response.reasoning_summary_text.delta", delta: "Preserved" },
{ type: "response.reasoning_summary_text.done", text: "Preserved" },
{ type: "response.reasoning_text.done", text: "Preserved" },
],
},
{
item: { type: "function_call", call_id: "call_1", name: "lookup" },
events: [
{ type: "response.function_call_arguments.delta", delta: '{"query":"Preserved"}' },
{ type: "response.function_call_arguments.done", arguments: '{"query":"Preserved"}' },
],
},
]
const routings = [
{ name: "empty item and event IDs", id: "", item_id: "" },
{ name: "empty event ID with registered index", id: "item_1", item_id: "", output_index: 2 },
{ name: "empty stored ID with registered index", id: "", item_id: "wrong_item", output_index: 2 },
{ name: "empty item and event IDs with registered index", id: "", item_id: "", output_index: 2 },
]
fixtures.forEach((fixture) => {
fixture.events.forEach((event) => {
routings.forEach((routing) => {
it.effect(`${event.type} preserves content with ${routing.name}`, () =>
Effect.gen(function* () {
const item = { ...fixture.item, id: routing.id }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: routing.output_index, item },
{ ...event, item_id: routing.item_id, output_index: routing.output_index },
{ type: "response.output_item.done", output_index: routing.output_index, item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const metadata = { openresponses: { itemId: routing.id } }
if (fixture.item.type === "function_call") {
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "Preserved" },
providerMetadata: metadata,
}),
])
return
}
if (fixture.item.type === "reasoning") {
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Preserved",
providerMetadata: {
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
},
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
return
}
expect(response.message.content).toEqual([
{ type: "text", text: "Preserved", providerMetadata: metadata },
])
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
expect.objectContaining({ id: routing.id, providerMetadata: metadata }),
])
}),
)
})
})
})
routings.forEach((routing) => {
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
Effect.gen(function* () {
const address = { item_id: routing.item_id, output_index: routing.output_index }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: routing.output_index,
item: { type: "reasoning", id: routing.id },
},
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", ...address, summary_index: 0, delta: "First." },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 0, text: "First." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 0 },
{ type: "response.reasoning_summary_part.added", ...address, summary_index: 1 },
{ type: "response.reasoning_summary_text.done", ...address, summary_index: 1, text: "Second." },
{ type: "response.reasoning_summary_part.done", ...address, summary_index: 1 },
{
type: "response.completed",
response: { output: [{ type: "reasoning", id: routing.id, encrypted_content: "final-state" }] },
},
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "First.",
providerMetadata: { openresponses: { itemId: routing.id } },
},
{
type: "reasoning",
text: "Second.",
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
},
])
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
expect.objectContaining({
id: `${routing.id}:0`,
providerMetadata: { openresponses: { itemId: routing.id } },
}),
expect.objectContaining({
id: `${routing.id}:1`,
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
}),
])
}),
)
})
it.effect("reconciles pending empty-ID function arguments from completed output", () =>
Effect.gen(function* () {
const item = { type: "function_call", id: "", call_id: "call_1", name: "lookup" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.function_call_arguments.delta", item_id: "", delta: '{"query":"partial' },
{
type: "response.completed",
response: { output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.toolCalls).toEqual([
expect.objectContaining({
id: "call_1",
name: "lookup",
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "" } },
}),
])
}),
)
it.effect("treats null output items as no-ops without disturbing registered items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "Before " },
{ type: "response.output_item.added", output_index: 0, item: null },
{ type: "response.output_item.done", output_index: 0, item: null },
{ type: "response.output_text.delta", output_index: 0, item_id: "wrong_item", delta: "after" },
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
expect(response.events.map((event) => event.type)).toEqual([
"step-start",
"text-start",
"text-delta",
"text-delta",
"text-end",
"step-finish",
"finish",
])
}),
)
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
Effect.gen(function* () {
yield* Effect.forEach(
[
...fixtures.flatMap((fixture) => fixture.events.map((event) => ({ item: fixture.item, event }))),
...["response.reasoning_summary_part.added", "response.reasoning_summary_part.done"].map((type) => ({
item: { type: "reasoning" },
event: { type, summary_index: 0 },
})),
],
(fixture) =>
Effect.forEach([undefined, null, 0, false, {}, []], (item_id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { ...fixture.item, id: "item_1" },
},
{ ...fixture.event, output_index: 0, item_id },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
)
}),
)
it.effect("keeps malformed output item IDs invalid", () =>
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
}),
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -2229,6 +2229,35 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts empty IDs for native reasoning text deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "", delta: "Raw" },
{
type: "response.output_item.done",
output_index: 1,
item: { type: "reasoning", id: "", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Raw",
providerMetadata: { openai: { itemId: "", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -0,0 +1,137 @@
import { expect, test } from "@playwright/test"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { once } from "node:events"
const legacy = `
self.addEventListener("install", event => event.waitUntil(
caches.open("workbox-precache-v2-" + self.registration.scope).then(cache =>
cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"])
)
))
self.addEventListener("fetch", event => {
if (event.request.mode === "navigate") {
event.respondWith(caches.match("/index.html"))
return
}
event.respondWith(caches.match(event.request).then(response => response || fetch(event.request)))
})
`
const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({
site: async ({}, use) => {
const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8")
const state = { version: "old", repaired: false }
const server = createServer((request, response) => {
const pathname = new URL(request.url ?? "/", "http://localhost").pathname
const prefix = state.version === "old" ? "/assets" : "/_assets"
response.setHeader("cache-control", "no-store")
if (pathname === "/sw.js") {
response.setHeader("content-type", "text/javascript")
response.end(state.version === "old" ? legacy : worker)
return
}
if (pathname === `${prefix}/app-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`import "${prefix}/startup-${state.version}.js"`)
return
}
if (pathname === `${prefix}/startup-${state.version}.js`) {
response.setHeader("content-type", "text/javascript")
response.end(`
document.getElementById("root").innerHTML = '<h1>${state.version}</h1><label>Draft<input></label><button>Load older chunk</button><output></output>'
document.querySelector("button").onclick = () => import("/assets/lazy-old.js")
`)
return
}
if (
(pathname === "/assets/lazy-old.js" && state.version === "old") ||
(pathname === "/_assets/retry.js" && state.repaired)
) {
response.setHeader("content-type", "text/javascript")
response.end('document.querySelector("output").textContent = "Older chunk loaded"')
return
}
// Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself.
response.setHeader("content-type", "text/html")
response.end(`<div id="root"></div><script type="module" src="${prefix}/app-${state.version}.js"></script>`)
})
server.listen(0, "127.0.0.1")
await once(server, "listening")
const address = server.address()
if (!address || typeof address === "string") throw new Error("Expected a TCP address")
try {
await use({
url: `http://127.0.0.1:${address.port}`,
upgrade: () => (state.version = "new"),
repair: () => (state.repaired = true),
})
} finally {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
},
})
fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => {
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("old")
await page.getByLabel("Draft").fill("Keep this unsent prompt")
site.upgrade()
await page.evaluate(async () => {
const cache = await caches.open("opencode-assets")
await cache.put(
"/_assets/startup-new.js",
new Response("<html>stale fallback</html>", {
headers: { "content-type": "text/html" },
}),
)
const changed = new Promise<void>((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }),
)
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) throw new Error("Missing legacy worker")
await registration.update()
await changed
})
await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt")
await page.getByRole("button", { name: "Load older chunk" }).click()
await expect(page.getByRole("status")).toHaveText("Older chunk loaded")
await page.goto(`${site.url}/workspace/example`)
await expect(page.getByRole("heading")).toHaveText("new")
await expect
.poll(() =>
page.evaluate(async () =>
(await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"),
),
)
.toBe("text/javascript")
})
fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => {
site.upgrade()
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
await page.evaluate(async () => {
await navigator.serviceWorker.register("/sw.js")
await navigator.serviceWorker.ready
})
await page.goto(site.url)
await expect(page.getByRole("heading")).toHaveText("new")
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/html",
)
site.repair()
expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe(
"text/javascript",
)
})
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
outputDir: "../test-results/service-worker",
timeout: 30_000,
use: { browserName: "chromium" },
})
+1
View File
@@ -28,6 +28,7 @@
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
+4 -1
View File
@@ -9,7 +9,10 @@ const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
export default defineConfig({
testDir: "./e2e",
testIgnore: process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
testIgnore: [
"service-worker/**",
process.env.OPENCODE_PERFORMANCE === "1" ? "performance/**/*.test.ts" : "performance/**",
],
outputDir: "./e2e/test-results",
timeout: 60_000,
expect: {
+3 -3
View File
@@ -1,10 +1,10 @@
/assets/*.js
/_assets/*.js
Content-Type: application/javascript
/assets/*.mjs
/_assets/*.mjs
Content-Type: application/javascript
/assets/*.css
/_assets/*.css
Content-Type: text/css
/*.js
+32 -20
View File
@@ -29,33 +29,44 @@ export default defineConfig({
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
clientsClaim: false,
skipWaiting: false,
skipWaiting: true,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/],
globPatterns: [
"index.html",
"site.webmanifest",
"favicon*",
"apple-touch-icon*",
"web-app-manifest*",
"assets/index-*.{js,css}",
"assets/session-*.js",
"assets/IBMPlexMono-Text-*.woff2",
"assets/Inter.ttf",
"assets/JetBrainsMonoNerdFontMono-Regular.woff2",
],
// Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade.
navigateFallback: null,
globPatterns: [],
runtimeCaching: [
{
urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.startsWith("/assets/"),
urlPattern: ({ url }) =>
url.origin === self.location.origin &&
(url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")),
handler: "CacheFirst",
options: {
cacheName: "opencode-assets",
cacheableResponse: {
statuses: [200],
},
plugins: [
{
cachedResponseWillBeUsed: async ({ request, cachedResponse }) => {
if (
cachedResponse?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "")
)
return cachedResponse
// Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler.
const response = await caches.match(request, {
cacheName: `workbox-precache-v2-${self.location.origin}/`,
})
return response?.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null
},
cacheWillUpdate: async ({ response }) =>
response.status === 200 &&
!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "")
? response
: null,
},
],
expiration: {
maxEntries: 1000,
},
@@ -72,6 +83,7 @@ export default defineConfig({
port: 3000,
},
build: {
assetsDir: "_assets",
target: "esnext",
sourcemap: true,
},
+1 -1
View File
@@ -27,7 +27,7 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/tui": "workspace:*",
+2
View File
@@ -26,6 +26,8 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
if (key.startsWith("_assets/") && assets[key] === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+19 -2
View File
@@ -19,7 +19,7 @@ describe("web UI", () => {
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"_assets/app.js": await Bun.file(asset).text(),
"sw.js": "service worker",
"registerSW.js": "registration",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
@@ -53,7 +53,16 @@ describe("web UI", () => {
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
yield* Effect.forEach(["/_assets/old.js", "/_assets/old.css", "/_assets/missing"], (pathname) =>
Effect.gen(function* () {
const missing = yield* Effect.promise(() => fetch(`${origin}${pathname}`))
expect(missing.status).toBe(404)
expect(missing.headers.get("cache-control")).toBe("no-store")
expect(yield* Effect.promise(() => missing.text())).toBe("")
}),
)
const script = yield* Effect.promise(() => fetch(`${origin}/_assets/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
expect(script.headers.get("content-type")).toContain("javascript")
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
@@ -74,6 +83,14 @@ describe("web UI", () => {
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
expect(dotted.status).toBe(200)
expect(yield* Effect.promise(() => dotted.text())).toContain("embedded")
const legacy = yield* Effect.promise(() => fetch(`${origin}/assets/missing.js`))
expect(legacy.status).toBe(200)
expect(yield* Effect.promise(() => legacy.text())).toContain("embedded")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
+1 -1
View File
@@ -118,7 +118,7 @@
"@ff-labs/fff-node": "0.10.5",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/pty": "0.1.9",
"@opencode-ai/pty": "0.1.10",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
+70 -91
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { KV } from "./kv.js"
import { ModelsDevCache } from "./models-dev/cache.js"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
// Digest of the raw body, persisted so refresh() can skip republishing a
// byte-identical catalog. Optional for entries written before it existed.
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
return bundledCache
}),
),
)
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export function bodyDigest(text: string) {
return Hash.sha256(text)
}
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const cache = yield* ModelsDevCache.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
})
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
)
})
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.orElseSucceed(() => undefined),
// Persistence only seeds a runtime. Refresh never reloads this seed over
// a catalog that was successfully fetched but could not be saved.
// The service owns initialization so cancelling a reader cannot cancel it.
const initialized = yield* Effect.forkScoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const stored = options?.file
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
: yield* cache.read(source)
if (!stored) return
const data = normalize(yield* decodeCatalog(stored.body))
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
),
)
: Effect.undefined
if (state.data) return
if (options?.snapshot !== false) {
Object.assign(state, yield* bundledSnapshot)
return
}
if (!fetch) state.data = []
}).pipe(Effect.orDie),
)
// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
const digest = bodyDigest(text)
if (!force && state.data && state.digest === digest) {
state.checkedAt = Date.now()
return state.data
}
const data = normalize(yield* decodeCatalog(text))
Object.assign(state, { data, digest, checkedAt: Date.now() })
yield* bus.publish(ModelsDev.Event.Refreshed, {})
// Adopt and publish before attempting persistence. A missing or broken
// cache must not prevent live updates, including in filesystem-less runtimes.
if (!options?.file)
yield* cache.write(source, text).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return data
})
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
return catalog
const get = Effect.fn("ModelsDev.get")(function* () {
yield* Fiber.join(initialized)
if (state.data) return state.data
return yield* lock
.withPermit(
Effect.gen(function* () {
return state.data ?? (yield* update())
}),
)
.pipe(Effect.orDie)
})
const populate = Effect.gen(function* () {
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return bundled
if (!fetch) return []
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
yield* Fiber.join(initialized)
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
yield* update(force)
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
Effect.orDie,
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
),
)
})
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
})
}
+60
View File
@@ -0,0 +1,60 @@
export * as ModelsDevCache from "./cache.js"
import path from "path"
import { Context, Effect, FileSystem, Layer, Option } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
export interface Entry {
readonly body: string
readonly updatedAt: number
}
export interface Interface {
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const read = Effect.fn("ModelsDevCache.read")(
function* (source: string) {
const file = path.join(directory, `${Hash.fast(source)}.json`)
const body = yield* fs.readFileString(file)
const info = yield* fs.stat(file)
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
},
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
)
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
yield* fs.makeDirectory(directory, { recursive: true })
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
yield* fs.writeFileString(temporary, body)
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
}, Effect.scoped)
return Service.of({ read, write })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [LayerNodePlatform.filesystem, Global.node],
})
export const disabledLayer = Layer.succeed(
Service,
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
)
+111
View File
@@ -0,0 +1,111 @@
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
const source = "https://models.opencode.ai"
const it = testEffect(
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
[Global.node, tempGlobalLayer],
]),
)
it.live("returns undefined for a missing catalog", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
expect(yield* cache.read(source)).toBeUndefined()
}),
)
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
const modified = new Date("2026-01-01T00:00:00Z")
yield* cache.write(source, body)
expect(yield* fs.readFileString(file)).toBe(body)
yield* fs.utimes(file, modified, modified)
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
}),
)
it.live("isolates catalogs by source including the default source", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const custom = "https://models.example.com"
yield* cache.write(source, "default catalog")
expect(yield* cache.read(custom)).toBeUndefined()
yield* cache.write(custom, "custom catalog")
expect((yield* cache.read(source))?.body).toBe("default catalog")
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
}),
)
it.live("replaces an existing catalog without leaving temporary files", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
yield* cache.write(source, "old catalog")
yield* cache.write(source, "new catalog")
expect((yield* cache.read(source))?.body).toBe("new catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const file = path.join(directory, `${Hash.fast(source)}.json`)
yield* fs.makeDirectory(file, { recursive: true })
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
expect(error._tag).toBe("PlatformError")
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
expect((yield* fs.stat(file)).type).toBe("Directory")
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
}),
)
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const staged = yield* Deferred.make<string>()
yield* cache.write(source, "old catalog")
// Pause only the commit; staging and cleanup still use the real filesystem.
const writer = yield* ModelsDevCache.Service.pipe(
Effect.flatMap((service) => service.write(source, "new catalog")),
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
Effect.provideService(FileSystem.FileSystem, {
...fs,
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
}),
Effect.forkScoped,
)
const temporary = yield* Deferred.await(staged)
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
expect((yield* cache.read(source))?.body).toBe("old catalog")
yield* Fiber.interrupt(writer)
expect((yield* cache.read(source))?.body).toBe("old catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
+285 -61
View File
@@ -1,18 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import path from "path"
import { Deferred, Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { Provider } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const cacheKey = "models-dev:catalog"
const source = "https://models.opencode.ai"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
)
interface MockCache {
readonly values: Map<string, KV.Value>
readonly values: Map<string, ModelsDevCache.Entry>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeMockCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: (source, body) =>
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
const buildLayer = (
state: Ref.Ref<MockState>,
cache: MockCache,
options: ModelsDev.Options = { fetch: false },
persistence = makeMockCache(cache),
) =>
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
[ModelsDevCache.node, persistence],
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeFailingWriteCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: () => Effect.die(new Error("Cache write failed")),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
cache.values.set(source, { updatedAt, body: text })
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
@@ -218,7 +219,7 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
it.live("get() returns normalized snapshots from the persisted cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCacheText(cache, "{")
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
it.live("get() still populates the catalog when persistence fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
expect(cache.values.has(source)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
for (const seeded of [false, true]) {
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
Effect.gen(function* () {
const cache = makeCache()
if (seeded) writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runDrain,
Effect.andThen(() => models.get()),
Effect.forkScoped({ startImmediately: true }),
)
yield* models.refresh(true)
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* models.refresh()
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
}),
)
}
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect((yield* models.get()).length).toBeGreaterThan(0)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixtureSnapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.die(new Error("Cache read failed")),
write: () => Effect.void,
}),
),
),
)
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const writing = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
const event = yield* bus
.subscribe(ModelsDev.Event.Refreshed)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
yield* Deferred.await(writing)
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(refresh)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.succeed(cache.values.get(source)),
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
}),
),
),
)
}),
)
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const releaseRead = yield* Deferred.make<void>()
const fetching = yield* Deferred.make<void>()
const releaseFetch = yield* Deferred.make<void>()
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[
ModelsDevCache.node,
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(releaseRead)),
Effect.as(undefined),
),
write: () => Effect.void,
}),
],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Deferred.succeed(fetching, undefined).pipe(
Effect.andThen(Deferred.await(releaseFetch)),
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
),
),
),
],
]),
)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
yield* Deferred.await(reading)
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(releaseRead, undefined)
yield* Deferred.await(fetching)
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
yield* Deferred.succeed(releaseFetch, undefined)
}).pipe(Effect.provide(layer))
}),
)
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(reading)
yield* Fiber.interrupt(first)
yield* Deferred.succeed(release, undefined)
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
),
write: () => Effect.void,
}),
),
),
)
}),
)
it.live("custom source URLs do not read or overwrite the default source cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
)
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
}),
)
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
Effect.gen(function* () {
const dir = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
const file = path.join(dir.path, "catalog.json")
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
const state = yield* Ref.make(initialState)
const cacheCalls: string[] = []
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ file, fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Effect.sync(() => {
cacheCalls.push("read")
return undefined
}),
write: () => Effect.sync(() => void cacheCalls.push("write")),
}),
),
),
)
expect((yield* Ref.get(state)).calls).toEqual([])
expect(cacheCalls).toEqual([])
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
it.live("get() retains the live catalog instead of rereading persistence", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when the KV entry is stale", () =>
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const seeded = structuredClone(cache.values.get(cacheKey))
const seeded = structuredClone(cache.values.get(source))
// The server serves a byte-identical body, so the refresh still hits
// the network but must not rewrite the cache or publish Refreshed.
const state = yield* Ref.make(initialState)
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toEqual(seeded)
expect(cache.values.get(source)).toEqual(seeded)
}),
)
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
Effect.gen(function* () {
const cache = makeCache()
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
}),
)
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
for (const body of ["{", JSON.stringify({ broken: {} })]) {
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body })
yield* provided(
state,
cache,
Effect.gen(function* () {
const models = yield* ModelsDev.Service
const before = yield* models.get()
yield* models.refresh(true)
expect(yield* models.get()).toBe(before)
}),
)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
}),
)
}
})
+3
View File
@@ -8,6 +8,7 @@ import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.wor
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { Pty } from "@opencode-ai/core/pty"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Vcs } from "@opencode-ai/core/vcs"
@@ -30,6 +31,7 @@ import type { ServerOptions } from "./options"
* backs them; Snapshot and Vcs degrade to no-op results.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only, and stdio MCP reports the same no-plane failure as Shell.
* - The models.dev catalog is memory-only, with no local filesystem cache.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
@@ -81,6 +83,7 @@ export function replacements(options: Options): LayerNode.Replacements {
[Vcs.node, vcsLayer],
[FileSystem.node, fileSystemLayer],
[FileSystemSearch.node, fileSystemSearchLayer],
[ModelsDevCache.node, ModelsDevCache.disabledLayer],
[Pty.node, ptyLayer],
// Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm
// install, or import of plugin code from disk.
+60 -1
View File
@@ -1,5 +1,11 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, FileSystem, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
import { it } from "../../core/test/lib/effect"
import { ServerWorkerd } from "../src/workerd"
@@ -31,3 +37,56 @@ it.live("boots the workerd profile over durable object storage", () =>
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
)
it.live("refreshes a memory-only catalog without a local filesystem", () =>
Effect.gen(function* () {
const name = yield* Ref.make("Acme One")
const replacements: LayerNode.Replacements = [
...ServerWorkerd.replacements({ storage: makeDurableObjectStorage() }),
[ModelsDev.node, ModelsDev.configured({ fetch: false, snapshot: false })],
[Global.node, Layer.succeed(Global.Service, Global.make())],
[LayerNodePlatform.filesystem, FileSystem.layerNoop({})],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
return HttpClientResponse.fromWeb(
request,
Response.json({
acme: {
id: "acme",
name: yield* Ref.get(name),
env: [],
npm: "@ai-sdk/openai-compatible",
models: {},
},
}),
)
}),
),
),
],
]
yield* Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const models = yield* ModelsDev.Service
yield* cache.write("https://models.opencode.ai", "not persisted")
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
expect(yield* models.get()).toEqual([])
yield* models.refresh(true)
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme One"])
yield* Ref.set(name, "Acme Two")
yield* models.refresh(true)
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme Two"])
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
}).pipe(
Effect.provide(
Layer.fresh(LayerNode.compile(LayerNode.group([ModelsDev.node, ModelsDevCache.node]), replacements)),
),
)
}),
)