Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/console/function/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import { User } from "@opencode-ai/console-core/user.js"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"

type Env = {
AuthStorage: KVNamespace
AuthStorage: Parameters<typeof CloudflareStorage>[0]["namespace"]
}

export const subjects = createSubjects({
Expand Down
15 changes: 10 additions & 5 deletions packages/desktop/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,16 @@ export default function Page() {
</div>
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
<Switch fallback="FREE">
<Match when={i.cost?.input > 10}>$$$</Match>
<Match when={i.cost?.input > 1}>$$</Match>
<Match when={i.cost?.input > 0.1}>$</Match>
</Switch>
{(() => {
const inputCost = i.cost?.input ?? 0
return (
<Switch fallback="FREE">
<Match when={inputCost > 10}>$$$</Match>
<Match when={inputCost > 1}>$$</Match>
<Match when={inputCost > 0.1}>$</Match>
</Switch>
)
})()}
</div>
</Tooltip>
</div>
Expand Down
74 changes: 54 additions & 20 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,49 +9,83 @@ export namespace ModelsDev {
const log = Log.create({ service: "models.dev" })
const filepath = path.join(Global.Path.cache, "models.json")

const isoDate = z
.string()
.regex(/^\d{4}-\d{2}(-\d{2})?$/, {
message: "Must be in YYYY-MM or YYYY-MM-DD format",
})

export const Model = z
.object({
id: z.string(),
name: z.string(),
release_date: z.string(),
name: z.string().min(1, "Model name cannot be empty"),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
cost: z.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
knowledge: isoDate.optional(),
release_date: isoDate,
last_updated: isoDate,
modalities: z.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
}),
limit: z.object({
context: z.number(),
output: z.number(),
}),
modalities: z
open_weights: z.boolean(),
cost: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
input: z.number().min(0, "Input price cannot be negative"),
output: z.number().min(0, "Output price cannot be negative"),
reasoning: z.number().min(0, "Reasoning price cannot be negative").optional(),
cache_read: z.number().min(0, "Cache read price cannot be negative").optional(),
cache_write: z.number().min(0, "Cache write price cannot be negative").optional(),
input_audio: z.number().min(0, "Audio input price cannot be negative").optional(),
output_audio: z.number().min(0, "Audio output price cannot be negative").optional(),
})
.optional(),
limit: z.object({
context: z.number().min(0, "Context window must be positive"),
output: z.number().min(0, "Output tokens must be positive"),
}),
alpha: z.boolean().optional(),
beta: z.boolean().optional(),
experimental: z.boolean().optional(),
options: z.record(z.string(), z.any()),
provider: z.object({ npm: z.string() }).optional(),
options: z.record(z.string(), z.any()).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
})
.refine(
(data) => !(data.reasoning === false && data.cost?.reasoning !== undefined),
{
message: "Cannot set cost.reasoning when reasoning is false",
path: ["cost", "reasoning"],
},
)
.meta({
ref: "Model",
})
export type Model = z.infer<typeof Model>

export const Provider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
api: z
.string()
.optional(),
name: z.string().min(1, "Provider name cannot be empty"),
env: z.array(z.string()).min(1, "Provider env cannot be empty"),
id: z.string(),
npm: z.string().optional(),
npm: z.string().min(1, "Provider npm module cannot be empty"),
doc: z.string().min(1, "Please provide provider documentation link"),
models: z.record(z.string(), Model),
options: z.record(z.string(), z.any()).optional(),
})
.refine(
(data) =>
(data.npm === "@ai-sdk/openai-compatible" && data.api !== undefined) ||
(data.npm !== "@ai-sdk/openai-compatible" && data.api === undefined),
{
message: "'api' field is required if and only if npm is '@ai-sdk/openai-compatible'",
path: ["api"],
},
)
.meta({
ref: "Provider",
})
Expand Down
90 changes: 54 additions & 36 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export namespace Provider {

if (!hasKey) {
for (const [key, value] of Object.entries(input.models)) {
if (value.cost.input === 0) continue
const inputCost = value.cost?.input ?? Infinity
if (inputCost === 0) continue
delete input.models[key]
}
}
Expand Down Expand Up @@ -239,52 +240,69 @@ export namespace Provider {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
npm: provider.npm ?? existing?.npm ?? "",
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
doc: provider.doc ?? existing?.doc ?? "https://opencode.ai",
models: existing?.models ?? {},
options: provider.options ?? existing?.options,
}

for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[modelID]
const existingModel = parsed.models[modelID]
const baseModalities =
model.modalities ??
existingModel?.modalities ?? {
input: ["text"],
output: ["text"],
}

const mergedCost =
model.cost || existingModel?.cost
? {
input: model.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model.cost?.output ?? existingModel?.cost?.output ?? 0,
reasoning: model.cost?.reasoning ?? existingModel?.cost?.reasoning,
cache_read: model.cost?.cache_read ?? existingModel?.cost?.cache_read,
cache_write: model.cost?.cache_write ?? existingModel?.cost?.cache_write,
input_audio: model.cost?.input_audio ?? existingModel?.cost?.input_audio,
output_audio: model.cost?.output_audio ?? existingModel?.cost?.output_audio,
}
: undefined

const parsedModel: ModelsDev.Model = {
id: model.id ?? modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
id: modelID,
name: model.name ?? existingModel?.name ?? modelID,
attachment: model.attachment ?? existingModel?.attachment ?? false,
reasoning: model.reasoning ?? existingModel?.reasoning ?? false,
temperature: model.temperature ?? existingModel?.temperature ?? false,
tool_call: model.tool_call ?? existingModel?.tool_call ?? true,
knowledge: model.knowledge ?? existingModel?.knowledge,
release_date: model.release_date ?? existingModel?.release_date ?? "1970-01",
last_updated:
model.last_updated ??
existingModel?.last_updated ??
model.release_date ??
existingModel?.release_date ??
"1970-01",
modalities: baseModalities,
open_weights: model.open_weights ?? existingModel?.open_weights ?? false,
cost: mergedCost,
limit:
model.limit ??
existingModel?.limit ?? {
context: 0,
output: 0,
},
modalities: model.modalities ??
existing?.modalities ?? {
input: ["text"],
output: ["text"],
},
provider: model.provider ?? existing?.provider,
alpha: model.alpha ?? existingModel?.alpha,
beta: model.beta ?? existingModel?.beta,
experimental: model.experimental ?? existingModel?.experimental,
options: {
...existingModel?.options,
...model.options,
},
provider: model.provider ?? existingModel?.provider,
}
parsed.models[modelID] = parsedModel
}
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ export namespace SessionPrompt {

using abort = lock(input.sessionID)

await handleUnsupportedAttachments({
message: userMsg,
modalities: model.info.modalities,
modelID: model.modelID,
providerID: model.providerID,
})

const system = await resolveSystemPrompt({
providerID: model.providerID,
modelID: model.info.id,
Expand Down Expand Up @@ -216,6 +223,7 @@ export namespace SessionPrompt {
providerID: model.providerID,
}),
(messages) => insertReminders({ messages, agent }),
(messages) => sanitizeMessages(messages, model.info.modalities),
)
if (step === 0)
ensureTitle({
Expand Down Expand Up @@ -548,6 +556,62 @@ export namespace SessionPrompt {
return tools
}

function modalityFromMime(mime: string) {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
return undefined
}

function acceptsFile(modalities: ModelsDev.Model["modalities"], mime: string) {
if (mime === "text/plain") return true
if (mime === "application/x-directory") return true
const kind = modalityFromMime(mime)
if (!kind) return true
return modalities.input.includes(kind)
}

async function handleUnsupportedAttachments(input: {
message: MessageV2.WithParts
modalities: ModelsDev.Model["modalities"]
providerID: string
modelID: string
}) {
const skip = input.message.parts.filter(
(part): part is MessageV2.FilePart => part.type === "file" && !acceptsFile(input.modalities, part.mime),
)
if (skip.length === 0) return
const kinds = Array.from(new Set(skip.map((part) => modalityFromMime(part.mime) ?? "file")))
const label = kinds.join(", ")
const count = skip.length
const part: MessageV2.TextPart = {
id: Identifier.ascending("part"),
messageID: input.message.info.id,
sessionID: input.message.info.sessionID,
type: "text",
synthetic: true,
text: `Skipped ${count} attachment${count === 1 ? "" : "s"} (${label}) because ${input.providerID}/${input.modelID} does not accept those inputs.`,
}
input.message.parts.push(part)
await Session.updatePart(part)
}

function sanitizeMessages(messages: MessageV2.WithParts[], modalities: ModelsDev.Model["modalities"]) {
return messages.map((msg) => {
if (msg.info.role !== "user") return msg
const parts = msg.parts.filter((part) => {
if (part.type !== "file") return true
return acceptsFile(modalities, part.mime)
})
if (parts.length === msg.parts.length) return msg
return {
...msg,
parts,
}
})
}

async function createUserMessage(input: PromptInput) {
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
Expand Down
Loading