Skip to content

Commit 6d7e677

Browse files
committed
feat(attachments): skip unsupported media and warn users
1 parent 9795f67 commit 6d7e677

File tree

8 files changed

+367
-89
lines changed

8 files changed

+367
-89
lines changed

packages/opencode/src/provider/models.ts

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +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
}),
33+
open_weights: z.boolean(),
34+
cost: z
35+
.object({
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(),
43+
})
44+
.optional(),
2745
limit: z.object({
28-
context: z.number(),
29-
output: z.number(),
46+
context: z.number().min(0, "Context window must be positive"),
47+
output: z.number().min(0, "Output tokens must be positive"),
3048
}),
49+
alpha: z.boolean().optional(),
50+
beta: z.boolean().optional(),
3151
experimental: z.boolean().optional(),
32-
options: z.record(z.string(), z.any()),
33-
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(),
3454
})
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+
)
3562
.meta({
3663
ref: "Model",
3764
})
3865
export type Model = z.infer<typeof Model>
3966

4067
export const Provider = z
4168
.object({
42-
api: z.string().optional(),
43-
name: z.string(),
44-
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"),
4574
id: z.string(),
46-
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"),
4777
models: z.record(z.string(), Model),
78+
options: z.record(z.string(), z.any()).optional(),
4879
})
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+
)
4989
.meta({
5090
ref: "Provider",
5191
})

packages/opencode/src/provider/provider.ts

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -195,47 +195,64 @@ export namespace Provider {
195195
const existing = database[providerID]
196196
const parsed: ModelsDev.Provider = {
197197
id: providerID,
198-
npm: provider.npm ?? existing?.npm,
198+
npm: provider.npm ?? existing?.npm ?? "",
199199
name: provider.name ?? existing?.name ?? providerID,
200200
env: provider.env ?? existing?.env ?? [],
201201
api: provider.api ?? existing?.api,
202+
doc: provider.doc ?? existing?.doc ?? "https://opencode.ai",
202203
models: existing?.models ?? {},
204+
options: provider.options ?? existing?.options,
203205
}
204206

205207
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
206-
const existing = parsed.models[modelID]
208+
const existingModel = parsed.models[modelID]
209+
const baseModalities =
210+
model.modalities ??
211+
existingModel?.modalities ?? {
212+
input: ["text"],
213+
output: ["text"],
214+
}
207215
const parsedModel: ModelsDev.Model = {
208216
id: modelID,
209-
name: model.name ?? existing?.name ?? modelID,
210-
release_date: model.release_date ?? existing?.release_date,
211-
attachment: model.attachment ?? existing?.attachment ?? false,
212-
reasoning: model.reasoning ?? existing?.reasoning ?? false,
213-
temperature: model.temperature ?? existing?.temperature ?? false,
214-
tool_call: model.tool_call ?? existing?.tool_call ?? true,
217+
name: model.name ?? existingModel?.name ?? modelID,
218+
attachment: model.attachment ?? existingModel?.attachment ?? false,
219+
reasoning: model.reasoning ?? existingModel?.reasoning ?? false,
220+
temperature: model.temperature ?? existingModel?.temperature ?? false,
221+
tool_call: model.tool_call ?? existingModel?.tool_call ?? true,
222+
knowledge: model.knowledge ?? existingModel?.knowledge,
223+
release_date: model.release_date ?? existingModel?.release_date ?? "1970-01",
224+
last_updated:
225+
model.last_updated ??
226+
existingModel?.last_updated ??
227+
model.release_date ??
228+
existingModel?.release_date ??
229+
"1970-01",
230+
modalities: baseModalities,
231+
open_weights: model.open_weights ?? existingModel?.open_weights ?? false,
215232
cost:
216-
!model.cost && !existing?.cost
233+
!model.cost && !existingModel?.cost
217234
? {
218235
input: 0,
219236
output: 0,
220-
cache_read: 0,
221-
cache_write: 0,
222237
}
223238
: {
224-
cache_read: 0,
225-
cache_write: 0,
226-
...existing?.cost,
239+
...existingModel?.cost,
227240
...model.cost,
228241
},
229-
options: {
230-
...existing?.options,
231-
...model.options,
232-
},
233-
limit: model.limit ??
234-
existing?.limit ?? {
242+
limit:
243+
model.limit ??
244+
existingModel?.limit ?? {
235245
context: 0,
236246
output: 0,
237247
},
238-
provider: model.provider ?? existing?.provider,
248+
alpha: model.alpha ?? existingModel?.alpha,
249+
beta: model.beta ?? existingModel?.beta,
250+
experimental: model.experimental ?? existingModel?.experimental,
251+
options: {
252+
...existingModel?.options,
253+
...model.options,
254+
},
255+
provider: model.provider ?? existingModel?.provider,
239256
}
240257
parsed.models[modelID] = parsedModel
241258
}

packages/opencode/src/session/prompt.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ export namespace SessionPrompt {
161161
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
162162
using abort = lock(input.sessionID)
163163

164+
await handleUnsupportedAttachments({
165+
message: userMsg,
166+
modalities: model.info.modalities,
167+
modelID: model.modelID,
168+
providerID: model.providerID,
169+
})
170+
164171
const system = await resolveSystemPrompt({
165172
providerID: model.providerID,
166173
modelID: model.info.id,
@@ -215,6 +222,7 @@ export namespace SessionPrompt {
215222
providerID: model.providerID,
216223
}),
217224
(messages) => insertReminders({ messages, agent }),
225+
(messages) => sanitizeMessages(messages, model.info.modalities),
218226
)
219227
if (step === 0)
220228
ensureTitle({
@@ -501,6 +509,62 @@ export namespace SessionPrompt {
501509
return tools
502510
}
503511

512+
function modalityFromMime(mime: string) {
513+
if (mime.startsWith("image/")) return "image"
514+
if (mime.startsWith("audio/")) return "audio"
515+
if (mime.startsWith("video/")) return "video"
516+
if (mime === "application/pdf") return "pdf"
517+
return undefined
518+
}
519+
520+
function acceptsFile(modalities: ModelsDev.Model["modalities"], mime: string) {
521+
if (mime === "text/plain") return true
522+
if (mime === "application/x-directory") return true
523+
const kind = modalityFromMime(mime)
524+
if (!kind) return true
525+
return modalities.input.includes(kind)
526+
}
527+
528+
async function handleUnsupportedAttachments(input: {
529+
message: MessageV2.WithParts
530+
modalities: ModelsDev.Model["modalities"]
531+
providerID: string
532+
modelID: string
533+
}) {
534+
const skip = input.message.parts.filter(
535+
(part): part is MessageV2.FilePart => part.type === "file" && !acceptsFile(input.modalities, part.mime),
536+
)
537+
if (skip.length === 0) return
538+
const kinds = Array.from(new Set(skip.map((part) => modalityFromMime(part.mime) ?? "file")))
539+
const label = kinds.join(", ")
540+
const count = skip.length
541+
const part: MessageV2.TextPart = {
542+
id: Identifier.ascending("part"),
543+
messageID: input.message.info.id,
544+
sessionID: input.message.info.sessionID,
545+
type: "text",
546+
synthetic: true,
547+
text: `Skipped ${count} attachment${count === 1 ? "" : "s"} (${label}) because ${input.providerID}/${input.modelID} does not accept those inputs.`,
548+
}
549+
input.message.parts.push(part)
550+
await Session.updatePart(part)
551+
}
552+
553+
function sanitizeMessages(messages: MessageV2.WithParts[], modalities: ModelsDev.Model["modalities"]) {
554+
return messages.map((msg) => {
555+
if (msg.info.role !== "user") return msg
556+
const parts = msg.parts.filter((part) => {
557+
if (part.type !== "file") return true
558+
return acceptsFile(modalities, part.mime)
559+
})
560+
if (parts.length === msg.parts.length) return msg
561+
return {
562+
...msg,
563+
parts,
564+
}
565+
})
566+
}
567+
504568
async function createUserMessage(input: PromptInput) {
505569
const info: MessageV2.Info = {
506570
id: input.messageID ?? Identifier.ascending("message"),

0 commit comments

Comments
 (0)