fix(opencode): isolate Copilot model decode failures

This commit is contained in:
Aiden Cline
2026-06-01 01:37:27 -05:00
parent a78b3217a5
commit a19b167fbb
2 changed files with 68 additions and 57 deletions
@@ -1,66 +1,66 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
import { Option, Schema } from "effect"
export const schema = Schema.Struct({
data: Schema.Array(
const item = Schema.Struct({
model_picker_enabled: Schema.Boolean,
id: Schema.String,
name: Schema.String,
// every version looks like: `{model.id}-YYYY-MM-DD`
version: Schema.String,
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: Schema.optional(
Schema.Struct({
model_picker_enabled: Schema.Boolean,
id: Schema.String,
name: Schema.String,
// every version looks like: `{model.id}-YYYY-MM-DD`
version: Schema.String,
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: Schema.optional(
Schema.Struct({
state: Schema.optional(Schema.String),
}),
),
billing: Schema.optional(
Schema.Struct({
token_prices: Schema.optional(
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
}),
),
}),
),
capabilities: Schema.Struct({
family: Schema.String,
limits: Schema.optional(
Schema.Struct({
max_context_window_tokens: Schema.optional(Schema.Number),
max_output_tokens: Schema.optional(Schema.Number),
max_prompt_tokens: Schema.optional(Schema.Number),
vision: Schema.optional(
Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.optional(Schema.Boolean),
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.optional(Schema.Boolean),
vision: Schema.optional(Schema.Boolean),
}),
}),
state: Schema.optional(Schema.String),
}),
),
billing: Schema.optional(
Schema.Struct({
token_prices: Schema.optional(
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
}),
),
}),
),
capabilities: Schema.Struct({
family: Schema.String,
limits: Schema.optional(
Schema.Struct({
max_context_window_tokens: Schema.optional(Schema.Number),
max_output_tokens: Schema.optional(Schema.Number),
max_prompt_tokens: Schema.optional(Schema.Number),
vision: Schema.optional(
Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.optional(Schema.Boolean),
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.optional(Schema.Boolean),
vision: Schema.optional(Schema.Boolean),
}),
}),
})
type Item = Schema.Schema.Type<typeof schema>["data"][number]
export const schema = Schema.Struct({
data: Schema.Array(Schema.Unknown),
})
type Item = Schema.Schema.Type<typeof item>
type SelectableItem = Item & {
capabilities: Item["capabilities"] & {
limits: NonNullable<Item["capabilities"]["limits"]> & {
@@ -73,6 +73,7 @@ type SelectableItem = Item & {
}
}
const decodeModels = Schema.decodeUnknownSync(schema)
const decodeItem = Schema.decodeUnknownOption(item)
function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model {
const reasoning =
@@ -213,7 +214,12 @@ export async function get(
})
const result = { ...existing }
const remote = new Map(data.data.filter(selectable).map((m) => [m.id, m] as const))
const remote = new Map(
data.data.flatMap((raw) => {
const item = Option.getOrUndefined(decodeItem(raw))
return item && selectable(item) ? ([[item.id, item]] as const) : []
}),
)
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
@@ -161,6 +161,10 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
supports: {},
},
},
{
model_picker_enabled: false,
id: "ignored-non-chat-record",
},
],
}),
{ status: 200 },
@@ -179,6 +183,7 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
},
})
expect(models["incomplete-internal-model"]).toBeUndefined()
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {