Skip to content

Commit db88afe

Browse files
committed
feat(attachments): skip unsupported media and warn users
1 parent 9d30bc6 commit db88afe

File tree

8 files changed

+367
-100
lines changed

8 files changed

+367
-100
lines changed

packages/opencode/src/provider/models.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,49 +9,83 @@ export namespace ModelsDev {
99
const log = Log.create({ service: "models.dev" })
1010
const filepath = path.join(Global.Path.cache, "models.json")
1111

12+
const isoDate = z
13+
.string()
14+
.regex(/^\d{4}-\d{2}(-\d{2})?$/, {
15+
message: "Must be in YYYY-MM or YYYY-MM-DD format",
16+
})
17+
1218
export const Model = z
1319
.object({
1420
id: z.string(),
15-
name: z.string(),
16-
release_date: z.string(),
21+
name: z.string().min(1, "Model name cannot be empty"),
1722
attachment: z.boolean(),
1823
reasoning: z.boolean(),
1924
temperature: z.boolean(),
2025
tool_call: z.boolean(),
21-
cost: z.object({
22-
input: z.number(),
23-
output: z.number(),
24-
cache_read: z.number().optional(),
25-
cache_write: z.number().optional(),
26+
knowledge: isoDate.optional(),
27+
release_date: isoDate,
28+
last_updated: isoDate,
29+
modalities: z.object({
30+
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
31+
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
2632
}),
27-
limit: z.object({
28-
context: z.number(),
29-
output: z.number(),
30-
}),
31-
modalities: z
33+
open_weights: z.boolean(),
34+
cost: z
3235
.object({
33-
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
34-
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
36+
input: z.number().min(0, "Input price cannot be negative"),
37+
output: z.number().min(0, "Output price cannot be negative"),
38+
reasoning: z.number().min(0, "Reasoning price cannot be negative").optional(),
39+
cache_read: z.number().min(0, "Cache read price cannot be negative").optional(),
40+
cache_write: z.number().min(0, "Cache write price cannot be negative").optional(),
41+
input_audio: z.number().min(0, "Audio input price cannot be negative").optional(),
42+
output_audio: z.number().min(0, "Audio output price cannot be negative").optional(),
3543
})
3644
.optional(),
45+
limit: z.object({
46+
context: z.number().min(0, "Context window must be positive"),
47+
output: z.number().min(0, "Output tokens must be positive"),
48+
}),
49+
alpha: z.boolean().optional(),
50+
beta: z.boolean().optional(),
3751
experimental: z.boolean().optional(),
38-
options: z.record(z.string(), z.any()),
39-
provider: z.object({ npm: z.string() }).optional(),
52+
options: z.record(z.string(), z.any()).optional(),
53+
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
4054
})
55+
.refine(
56+
(data) => !(data.reasoning === false && data.cost?.reasoning !== undefined),
57+
{
58+
message: "Cannot set cost.reasoning when reasoning is false",
59+
path: ["cost", "reasoning"],
60+
},
61+
)
4162
.meta({
4263
ref: "Model",
4364
})
4465
export type Model = z.infer<typeof Model>
4566

4667
export const Provider = z
4768
.object({
48-
api: z.string().optional(),
49-
name: z.string(),
50-
env: z.array(z.string()),
69+
api: z
70+
.string()
71+
.optional(),
72+
name: z.string().min(1, "Provider name cannot be empty"),
73+
env: z.array(z.string()).min(1, "Provider env cannot be empty"),
5174
id: z.string(),
52-
npm: z.string().optional(),
75+
npm: z.string().min(1, "Provider npm module cannot be empty"),
76+
doc: z.string().min(1, "Please provide provider documentation link"),
5377
models: z.record(z.string(), Model),
78+
options: z.record(z.string(), z.any()).optional(),
5479
})
80+
.refine(
81+
(data) =>
82+
(data.npm === "@ai-sdk/openai-compatible" && data.api !== undefined) ||
83+
(data.npm !== "@ai-sdk/openai-compatible" && data.api === undefined),
84+
{
85+
message: "'api' field is required if and only if npm is '@ai-sdk/openai-compatible'",
86+
path: ["api"],
87+
},
88+
)
5589
.meta({
5690
ref: "Provider",
5791
})

packages/opencode/src/provider/provider.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -239,52 +239,64 @@ export namespace Provider {
239239
const existing = database[providerID]
240240
const parsed: ModelsDev.Provider = {
241241
id: providerID,
242-
npm: provider.npm ?? existing?.npm,
242+
npm: provider.npm ?? existing?.npm ?? "",
243243
name: provider.name ?? existing?.name ?? providerID,
244244
env: provider.env ?? existing?.env ?? [],
245245
api: provider.api ?? existing?.api,
246+
doc: provider.doc ?? existing?.doc ?? "https://opencode.ai",
246247
models: existing?.models ?? {},
248+
options: provider.options ?? existing?.options,
247249
}
248250

249251
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
250-
const existing = parsed.models[modelID]
252+
const existingModel = parsed.models[modelID]
253+
const baseModalities =
254+
model.modalities ??
255+
existingModel?.modalities ?? {
256+
input: ["text"],
257+
output: ["text"],
258+
}
251259
const parsedModel: ModelsDev.Model = {
252-
id: model.id ?? modelID,
253-
name: model.name ?? existing?.name ?? modelID,
254-
release_date: model.release_date ?? existing?.release_date,
255-
attachment: model.attachment ?? existing?.attachment ?? false,
256-
reasoning: model.reasoning ?? existing?.reasoning ?? false,
257-
temperature: model.temperature ?? existing?.temperature ?? false,
258-
tool_call: model.tool_call ?? existing?.tool_call ?? true,
260+
id: modelID,
261+
name: model.name ?? existingModel?.name ?? modelID,
262+
attachment: model.attachment ?? existingModel?.attachment ?? false,
263+
reasoning: model.reasoning ?? existingModel?.reasoning ?? false,
264+
temperature: model.temperature ?? existingModel?.temperature ?? false,
265+
tool_call: model.tool_call ?? existingModel?.tool_call ?? true,
266+
knowledge: model.knowledge ?? existingModel?.knowledge,
267+
release_date: model.release_date ?? existingModel?.release_date ?? "1970-01",
268+
last_updated:
269+
model.last_updated ??
270+
existingModel?.last_updated ??
271+
model.release_date ??
272+
existingModel?.release_date ??
273+
"1970-01",
274+
modalities: baseModalities,
275+
open_weights: model.open_weights ?? existingModel?.open_weights ?? false,
259276
cost:
260-
!model.cost && !existing?.cost
277+
!model.cost && !existingModel?.cost
261278
? {
262279
input: 0,
263280
output: 0,
264-
cache_read: 0,
265-
cache_write: 0,
266281
}
267282
: {
268-
cache_read: 0,
269-
cache_write: 0,
270-
...existing?.cost,
283+
...existingModel?.cost,
271284
...model.cost,
272285
},
273-
options: {
274-
...existing?.options,
275-
...model.options,
276-
},
277-
limit: model.limit ??
278-
existing?.limit ?? {
286+
limit:
287+
model.limit ??
288+
existingModel?.limit ?? {
279289
context: 0,
280290
output: 0,
281291
},
282-
modalities: model.modalities ??
283-
existing?.modalities ?? {
284-
input: ["text"],
285-
output: ["text"],
286-
},
287-
provider: model.provider ?? existing?.provider,
292+
alpha: model.alpha ?? existingModel?.alpha,
293+
beta: model.beta ?? existingModel?.beta,
294+
experimental: model.experimental ?? existingModel?.experimental,
295+
options: {
296+
...existingModel?.options,
297+
...model.options,
298+
},
299+
provider: model.provider ?? existingModel?.provider,
288300
}
289301
parsed.models[modelID] = parsedModel
290302
}

packages/opencode/src/session/prompt.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ export namespace SessionPrompt {
162162

163163
using abort = lock(input.sessionID)
164164

165+
await handleUnsupportedAttachments({
166+
message: userMsg,
167+
modalities: model.info.modalities,
168+
modelID: model.modelID,
169+
providerID: model.providerID,
170+
})
171+
165172
const system = await resolveSystemPrompt({
166173
providerID: model.providerID,
167174
modelID: model.info.id,
@@ -216,6 +223,7 @@ export namespace SessionPrompt {
216223
providerID: model.providerID,
217224
}),
218225
(messages) => insertReminders({ messages, agent }),
226+
(messages) => sanitizeMessages(messages, model.info.modalities),
219227
)
220228
if (step === 0)
221229
ensureTitle({
@@ -548,6 +556,62 @@ export namespace SessionPrompt {
548556
return tools
549557
}
550558

559+
function modalityFromMime(mime: string) {
560+
if (mime.startsWith("image/")) return "image"
561+
if (mime.startsWith("audio/")) return "audio"
562+
if (mime.startsWith("video/")) return "video"
563+
if (mime === "application/pdf") return "pdf"
564+
return undefined
565+
}
566+
567+
function acceptsFile(modalities: ModelsDev.Model["modalities"], mime: string) {
568+
if (mime === "text/plain") return true
569+
if (mime === "application/x-directory") return true
570+
const kind = modalityFromMime(mime)
571+
if (!kind) return true
572+
return modalities.input.includes(kind)
573+
}
574+
575+
async function handleUnsupportedAttachments(input: {
576+
message: MessageV2.WithParts
577+
modalities: ModelsDev.Model["modalities"]
578+
providerID: string
579+
modelID: string
580+
}) {
581+
const skip = input.message.parts.filter(
582+
(part): part is MessageV2.FilePart => part.type === "file" && !acceptsFile(input.modalities, part.mime),
583+
)
584+
if (skip.length === 0) return
585+
const kinds = Array.from(new Set(skip.map((part) => modalityFromMime(part.mime) ?? "file")))
586+
const label = kinds.join(", ")
587+
const count = skip.length
588+
const part: MessageV2.TextPart = {
589+
id: Identifier.ascending("part"),
590+
messageID: input.message.info.id,
591+
sessionID: input.message.info.sessionID,
592+
type: "text",
593+
synthetic: true,
594+
text: `Skipped ${count} attachment${count === 1 ? "" : "s"} (${label}) because ${input.providerID}/${input.modelID} does not accept those inputs.`,
595+
}
596+
input.message.parts.push(part)
597+
await Session.updatePart(part)
598+
}
599+
600+
function sanitizeMessages(messages: MessageV2.WithParts[], modalities: ModelsDev.Model["modalities"]) {
601+
return messages.map((msg) => {
602+
if (msg.info.role !== "user") return msg
603+
const parts = msg.parts.filter((part) => {
604+
if (part.type !== "file") return true
605+
return acceptsFile(modalities, part.mime)
606+
})
607+
if (parts.length === msg.parts.length) return msg
608+
return {
609+
...msg,
610+
parts,
611+
}
612+
})
613+
}
614+
551615
async function createUserMessage(input: PromptInput) {
552616
const info: MessageV2.Info = {
553617
id: input.messageID ?? Identifier.ascending("message"),

0 commit comments

Comments
 (0)