diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index e991e8c22b..d605b6034b 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -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[0]["namespace"] } export const subjects = createSubjects({ diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 128c6adfd5..35935b4b8f 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -237,11 +237,16 @@ export default function Page() {
- - 10}>$$$ - 1}>$$ - 0.1}>$ - + {(() => { + const inputCost = i.cost?.input ?? 0 + return ( + + 10}>$$$ + 1}>$$ + 0.1}>$ + + ) + })()}
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index adcf4004b2..58d204892c 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -9,35 +9,56 @@ 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", }) @@ -45,13 +66,26 @@ export namespace ModelsDev { 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", }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e0fe4be23c..c99475f000 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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] } } @@ -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 } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0ccb208c6c..98ce055471 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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, @@ -216,6 +223,7 @@ export namespace SessionPrompt { providerID: model.providerID, }), (messages) => insertReminders({ messages, agent }), + (messages) => sanitizeMessages(messages, model.info.modalities), ) if (step === 0) ensureTitle({ @@ -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"), diff --git a/packages/sdk/go/app.go b/packages/sdk/go/app.go index 19662f1005..d6e963e98f 100644 --- a/packages/sdk/go/app.go +++ b/packages/sdk/go/app.go @@ -52,16 +52,22 @@ func (r *AppService) Providers(ctx context.Context, query AppProvidersParams, op type Model struct { ID string `json:"id,required"` - Attachment bool `json:"attachment,required"` - Cost ModelCost `json:"cost,required"` - Limit ModelLimit `json:"limit,required"` Name string `json:"name,required"` - Options map[string]interface{} `json:"options,required"` + Attachment bool `json:"attachment,required"` Reasoning bool `json:"reasoning,required"` - ReleaseDate string `json:"release_date,required"` Temperature bool `json:"temperature,required"` ToolCall bool `json:"tool_call,required"` + Knowledge string `json:"knowledge"` + ReleaseDate string `json:"release_date,required"` + LastUpdated string `json:"last_updated,required"` + Modalities ModelModalities `json:"modalities,required"` + OpenWeights bool `json:"open_weights,required"` + Cost *ModelCost `json:"cost"` + Limit ModelLimit `json:"limit,required"` + Options map[string]interface{} `json:"options"` Experimental bool `json:"experimental"` + Alpha bool `json:"alpha"` + Beta bool `json:"beta"` Provider ModelProvider `json:"provider"` JSON modelJSON `json:"-"` } @@ -78,7 +84,13 @@ type modelJSON struct { ReleaseDate apijson.Field Temperature apijson.Field ToolCall apijson.Field + Knowledge apijson.Field + LastUpdated apijson.Field + Modalities apijson.Field + OpenWeights apijson.Field Experimental apijson.Field + Alpha apijson.Field + Beta apijson.Field Provider apijson.Field raw string ExtraFields map[string]apijson.Field @@ -93,11 +105,14 @@ func (r modelJSON) RawJSON() string { } type ModelCost struct { - Input float64 `json:"input,required"` - Output float64 `json:"output,required"` - CacheRead float64 `json:"cache_read"` - CacheWrite float64 `json:"cache_write"` - JSON modelCostJSON `json:"-"` + Input float64 `json:"input,required"` + Output float64 `json:"output,required"` + CacheRead float64 `json:"cache_read"` + CacheWrite float64 `json:"cache_write"` + Reasoning float64 `json:"reasoning"` + InputAudio float64 `json:"input_audio"` + OutputAudio float64 `json:"output_audio"` + JSON modelCostJSON `json:"-"` } // modelCostJSON contains the JSON metadata for the struct [ModelCost] @@ -106,6 +121,22 @@ type modelCostJSON struct { Output apijson.Field CacheRead apijson.Field CacheWrite apijson.Field + Reasoning apijson.Field + InputAudio apijson.Field + OutputAudio apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +type ModelModalities struct { + Input []string `json:"input,required"` + Output []string `json:"output,required"` + JSON modelModalitiesJSON `json:"-"` +} + +type modelModalitiesJSON struct { + Input apijson.Field + Output apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -141,13 +172,15 @@ func (r modelLimitJSON) RawJSON() string { } type ModelProvider struct { - Npm string `json:"npm,required"` + Npm string `json:"npm"` + API string `json:"api"` JSON modelProviderJSON `json:"-"` } // modelProviderJSON contains the JSON metadata for the struct [ModelProvider] type modelProviderJSON struct { Npm apijson.Field + API apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -161,23 +194,27 @@ func (r modelProviderJSON) RawJSON() string { } type Provider struct { - ID string `json:"id,required"` - Env []string `json:"env,required"` - Models map[string]Model `json:"models,required"` - Name string `json:"name,required"` - API string `json:"api"` - Npm string `json:"npm"` - JSON providerJSON `json:"-"` + ID string `json:"id,required"` + Name string `json:"name,required"` + Env []string `json:"env,required"` + Npm string `json:"npm,required"` + Doc string `json:"doc,required"` + API string `json:"api"` + Models map[string]Model `json:"models,required"` + Options map[string]interface{} `json:"options"` + JSON providerJSON `json:"-"` } // providerJSON contains the JSON metadata for the struct [Provider] type providerJSON struct { ID apijson.Field + Name apijson.Field Env apijson.Field Models apijson.Field - Name apijson.Field API apijson.Field Npm apijson.Field + Doc apijson.Field + Options apijson.Field raw string ExtraFields map[string]apijson.Field } diff --git a/packages/sdk/go/config.go b/packages/sdk/go/config.go index 561a35a0f8..0c558f67f4 100644 --- a/packages/sdk/go/config.go +++ b/packages/sdk/go/config.go @@ -1567,36 +1567,48 @@ func (r configProviderJSON) RawJSON() string { } type ConfigProviderModel struct { - ID string `json:"id"` - Attachment bool `json:"attachment"` - Cost ConfigProviderModelsCost `json:"cost"` - Experimental bool `json:"experimental"` - Limit ConfigProviderModelsLimit `json:"limit"` - Name string `json:"name"` - Options map[string]interface{} `json:"options"` - Provider ConfigProviderModelsProvider `json:"provider"` - Reasoning bool `json:"reasoning"` - ReleaseDate string `json:"release_date"` - Temperature bool `json:"temperature"` - ToolCall bool `json:"tool_call"` - JSON configProviderModelJSON `json:"-"` + ID string `json:"id"` + Name string `json:"name"` + Attachment bool `json:"attachment"` + Reasoning bool `json:"reasoning"` + Temperature bool `json:"temperature"` + ToolCall bool `json:"tool_call"` + Knowledge string `json:"knowledge"` + ReleaseDate string `json:"release_date"` + LastUpdated string `json:"last_updated"` + Modalities ConfigProviderModelsModalities `json:"modalities"` + OpenWeights bool `json:"open_weights"` + Cost *ConfigProviderModelsCost `json:"cost"` + Limit ConfigProviderModelsLimit `json:"limit"` + Options map[string]interface{} `json:"options"` + Experimental bool `json:"experimental"` + Alpha bool `json:"alpha"` + Beta bool `json:"beta"` + Provider ConfigProviderModelsProvider `json:"provider"` + JSON configProviderModelJSON `json:"-"` } // configProviderModelJSON contains the JSON metadata for the struct // [ConfigProviderModel] type configProviderModelJSON struct { ID apijson.Field + Name apijson.Field Attachment apijson.Field + Reasoning apijson.Field + Temperature apijson.Field + ToolCall apijson.Field + Knowledge apijson.Field + ReleaseDate apijson.Field + LastUpdated apijson.Field + Modalities apijson.Field + OpenWeights apijson.Field Cost apijson.Field Experimental apijson.Field Limit apijson.Field - Name apijson.Field Options apijson.Field Provider apijson.Field - Reasoning apijson.Field - ReleaseDate apijson.Field - Temperature apijson.Field - ToolCall apijson.Field + Alpha apijson.Field + Beta apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -1610,11 +1622,14 @@ func (r configProviderModelJSON) RawJSON() string { } type ConfigProviderModelsCost struct { - Input float64 `json:"input,required"` - Output float64 `json:"output,required"` - CacheRead float64 `json:"cache_read"` - CacheWrite float64 `json:"cache_write"` - JSON configProviderModelsCostJSON `json:"-"` + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cache_read"` + CacheWrite float64 `json:"cache_write"` + Reasoning float64 `json:"reasoning"` + InputAudio float64 `json:"input_audio"` + OutputAudio float64 `json:"output_audio"` + JSON configProviderModelsCostJSON `json:"-"` } // configProviderModelsCostJSON contains the JSON metadata for the struct @@ -1624,6 +1639,9 @@ type configProviderModelsCostJSON struct { Output apijson.Field CacheRead apijson.Field CacheWrite apijson.Field + Reasoning apijson.Field + InputAudio apijson.Field + OutputAudio apijson.Field raw string ExtraFields map[string]apijson.Field } @@ -1659,6 +1677,27 @@ func (r configProviderModelsLimitJSON) RawJSON() string { return r.raw } +type ConfigProviderModelsModalities struct { + Input []string `json:"input"` + Output []string `json:"output"` + JSON configProviderModelsModalitiesJSON `json:"-"` +} + +type configProviderModelsModalitiesJSON struct { + Input apijson.Field + Output apijson.Field + raw string + ExtraFields map[string]apijson.Field +} + +func (r *ConfigProviderModelsModalities) UnmarshalJSON(data []byte) (err error) { + return apijson.UnmarshalRoot(data, r) +} + +func (r configProviderModelsModalitiesJSON) RawJSON() string { + return r.raw +} + type ConfigProviderModelsProvider struct { Npm string `json:"npm,required"` JSON configProviderModelsProviderJSON `json:"-"` diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 706fff125e..5d7986f12c 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -398,16 +398,22 @@ export type Config = { [key: string]: { id?: string name?: string + knowledge?: string release_date?: string + last_updated?: string attachment?: boolean reasoning?: boolean temperature?: boolean tool_call?: boolean + open_weights?: boolean cost?: { - input: number - output: number + input?: number + output?: number cache_read?: number cache_write?: number + reasoning?: number + input_audio?: number + output_audio?: number } limit?: { context: number @@ -418,11 +424,14 @@ export type Config = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean + alpha?: boolean + beta?: boolean options?: { [key: string]: unknown } provider?: { - npm: string + npm?: string + api?: string } } } @@ -894,31 +903,40 @@ export type Command = { export type Model = { id: string name: string + knowledge?: string release_date: string + last_updated: string attachment: boolean reasoning: boolean temperature: boolean tool_call: boolean - cost: { + open_weights: boolean + modalities: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + cost?: { input: number output: number cache_read?: number cache_write?: number + reasoning?: number + input_audio?: number + output_audio?: number } limit: { context: number output: number } - modalities?: { - input: Array<"text" | "audio" | "image" | "video" | "pdf"> - output: Array<"text" | "audio" | "image" | "video" | "pdf"> - } experimental?: boolean - options: { + alpha?: boolean + beta?: boolean + options?: { [key: string]: unknown } provider?: { - npm: string + npm?: string + api?: string } } @@ -927,10 +945,14 @@ export type Provider = { name: string env: Array id: string - npm?: string + npm: string + doc: string models: { [key: string]: Model } + options?: { + [key: string]: unknown + } } export type Symbol = { diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4a891f2827..28ed4f7d03 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -782,6 +782,55 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) { var cmds []tea.Cmd + if a.Model != nil && a.Provider != nil { + limit := map[string]struct{}{} + for _, kind := range a.Model.Modalities.Input { + limit[kind] = struct{}{} + } + blocked := map[string]struct{}{} + for _, att := range prompt.Attachments { + if att.MediaType == "" { + continue + } + if att.Type != "file" { + continue + } + kind := "" + if strings.HasPrefix(att.MediaType, "image/") { + kind = "image" + } + if strings.HasPrefix(att.MediaType, "audio/") { + if kind == "" { + kind = "audio" + } + } + if strings.HasPrefix(att.MediaType, "video/") { + if kind == "" { + kind = "video" + } + } + if att.MediaType == "application/pdf" { + if kind == "" { + kind = "pdf" + } + } + if kind == "" { + continue + } + if _, ok := limit[kind]; ok { + continue + } + blocked[kind] = struct{}{} + } + if len(blocked) > 0 { + var kinds []string + for k := range blocked { + kinds = append(kinds, k) + } + msg := fmt.Sprintf("%s/%s skips %s attachments; switch to a compatible model to use them.", a.Provider.ID, a.Model.ID, strings.Join(kinds, ", ")) + cmds = append(cmds, toast.NewWarningToast(msg)) + } + } if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index a09e809f09..a1a4047d35 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -904,8 +904,10 @@ func (m *messagesComponent) renderHeader() string { } // Check if current model is a subscription model (cost is 0 for both input and output) - isSubscriptionModel := m.app.Model != nil && - m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0 + isSubscriptionModel := false + if m.app.Model != nil && m.app.Model.Cost != nil { + isSubscriptionModel = m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0 + } sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel) sessionInfo = styles.NewStyle().