Skip to content
Merged
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
45 changes: 33 additions & 12 deletions packages/opencode/src/config/permission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * as ConfigPermission from "./permission"
import { Schema, SchemaGetter } from "effect"
import { zod } from "@/util/effect-zod"
import z from "zod"
import { ZodOverride, zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

export const Action = Schema.Literals(["ask", "allow", "deny"])
Expand All @@ -18,17 +19,9 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>

// Known permission keys get explicit types — most are full Rule (either a
// single Action or a per-pattern object), but a handful of tools take no
// sub-target patterns and are Action-only. Unknown keys fall through the
// Record rest signature as Rule.
//
// StructWithRest canonicalises key order on decode (known first, then rest),
// which used to require the `__originalKeys` preprocess hack because
// `Permission.fromConfig` depended on the user's insertion order. That
// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
// permissions come before specifics, making the final precedence
// order-independent.
// Known permission keys get explicit types in the Effect schema for generated
// docs/types. Runtime config parsing uses `InfoZod` below so user key order is
// preserved for permission precedence.
const InputObject = Schema.StructWithRest(
Schema.Struct({
read: Schema.optional(Rule),
Expand Down Expand Up @@ -65,6 +58,33 @@ const InputSchema = Schema.Union([Action, InputObject])
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
typeof input === "string" ? { "*": input } : input

const InfoZod = z
.union([
zod(Action),
z
.object({
read: zod(Rule).optional(),
edit: zod(Rule).optional(),
glob: zod(Rule).optional(),
grep: zod(Rule).optional(),
list: zod(Rule).optional(),
bash: zod(Rule).optional(),
agent: zod(Rule).optional(),
task: zod(Rule).optional(),
external_directory: zod(Rule).optional(),
todowrite: zod(Action).optional(),
question: zod(Action).optional(),
webfetch: zod(Action).optional(),
websearch: zod(Action).optional(),
codesearch: zod(Action).optional(),
lsp: zod(Rule).optional(),
doom_loop: zod(Action).optional(),
skill: zod(Rule).optional(),
})
.catchall(zod(Rule)),
])
.transform(normalizeInput)
Comment thread
Astro-Han marked this conversation as resolved.

export const Info = InputSchema.pipe(
Schema.decodeTo(InputObject, {
decode: SchemaGetter.transform(normalizeInput),
Expand All @@ -75,6 +95,7 @@ export const Info = InputSchema.pipe(
}),
)
.annotate({ identifier: "PermissionConfig" })
.annotate({ [ZodOverride]: InfoZod })
.pipe(
// Walker already emits the decodeTo transform into the derived zod (see
// `encoded()` in effect-zod.ts), so just expose that directly.
Expand Down
77 changes: 49 additions & 28 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ function mimeToModality(mime: string): Modality | undefined {
return undefined
}

function filterEmptyMessageContent(msg: ModelMessage): ModelMessage | undefined {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered } as ModelMessage
}

export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000

// Maps npm package to the key the AI SDK expects for providerOptions
Expand Down Expand Up @@ -50,11 +66,7 @@ function isDeepSeekModelID(id: string) {
}

function isLegacyDeepSeekVariantID(id: string) {
return /(^|[/:])deepseek-(?:chat|reasoner|r1|v3)(?:[-/]|$)/.test(id.toLowerCase())
}

function isDeepSeekV4ID(id: string) {
return /(^|[/:])deepseek-v4(?:[-/]|$)/.test(id.toLowerCase())
return /(^|[/:])deepseek-(?:chat|reasoner|r1|v3)(?:[.\-/]|$)/.test(id.toLowerCase())
}

function normalizeMessages(
Expand All @@ -64,24 +76,13 @@ function normalizeMessages(
): ModelMessage[] {
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs
.map((msg) => {
if (typeof msg.content === "string") {
if (msg.content === "") return undefined
return msg
}
if (!Array.isArray(msg.content)) return msg
const filtered = msg.content.filter((part) => {
if (part.type === "text" || part.type === "reasoning") {
return part.text !== ""
}
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
if (model.api.npm === "@ai-sdk/anthropic") {
Comment thread
Astro-Han marked this conversation as resolved.
msgs = msgs.map(filterEmptyMessageContent).filter((msg): msg is ModelMessage => msg !== undefined)
}
Comment thread
Astro-Han marked this conversation as resolved.

// Bedrock specific transforms
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
msgs = msgs.map(filterEmptyMessageContent).filter((msg): msg is ModelMessage => msg !== undefined)
}

if (model.api.id.includes("claude")) {
Expand Down Expand Up @@ -187,6 +188,8 @@ function normalizeMessages(
return result
}

// This must run before interleaved-field extraction so the injected empty
// reasoning becomes providerOptions.reasoning_content on the next turn.
if (isDeepSeekModelID(model.api.id)) {
msgs = msgs.map((msg) => {
if (msg.role !== "assistant") return msg
Expand Down Expand Up @@ -427,6 +430,12 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null {
return null
}

function deepseekMajorVersion(apiId: string): number | undefined {
const match = apiId.toLowerCase().match(/(^|[/:])deepseek-v(\d+)(?:[.\-/]|$)/)
if (!match) return undefined
return Number.parseInt(match[2]!, 10)
}

export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}

Expand All @@ -436,7 +445,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
isLegacyDeepSeekVariantID(id) ||
id.includes("minimax") ||
id.includes("glm") ||
id.includes("mistral") ||
(id.includes("mistral") && model.api.npm !== "@ai-sdk/mistral") ||
id.includes("kimi") ||
Comment thread
Astro-Han marked this conversation as resolved.
id.includes("k2p5") ||
id.includes("qwen") ||
Expand Down Expand Up @@ -561,7 +570,10 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
// https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort
case "@ai-sdk/openai-compatible": {
const openaiCompatibleEfforts = [...WIDELY_SUPPORTED_EFFORTS]
if (isDeepSeekV4ID(model.api.id)) openaiCompatibleEfforts.push("max")
const deepseekMajor = deepseekMajorVersion(model.api.id)
if (deepseekMajor !== undefined && deepseekMajor >= 4) {
openaiCompatibleEfforts.push("max")
}
return Object.fromEntries(openaiCompatibleEfforts.map((effort) => [effort, { reasoningEffort: effort }]))
}

Expand Down Expand Up @@ -739,15 +751,23 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
]),
)

case "@ai-sdk/mistral":
case "@ai-sdk/mistral": {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral
return {}
// https://docs.mistral.ai/capabilities/reasoning/adjustable
if (!model.capabilities.reasoning) return {}
// Only Mistral Small 4 supports reasoning (mistral-small-2603, mistral-small-latest)
const mistralId = model.api.id.toLowerCase()
Comment thread
Astro-Han marked this conversation as resolved.
if (!mistralId.includes("mistral-small-2603") && !mistralId.includes("mistral-small-latest")) return {}
return {
high: { reasoningEffort: "high" },
}
}

case "@ai-sdk/cohere":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere
return {}

case "@ai-sdk/groq":
case "@ai-sdk/groq": {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq
const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS]
return Object.fromEntries(
Expand All @@ -758,6 +778,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
},
]),
)
}

case "@ai-sdk/perplexity":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
Expand Down
18 changes: 13 additions & 5 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -968,10 +968,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- ${JSON.stringify(cwd)}
cd -- "$OPENCODE_SHELL_CWD" || exit $?
unset OPENCODE_SHELL_CWD
eval ${JSON.stringify(input.command)}
`,
"pawwork",
"opencode",
],
},
bash: {
Expand All @@ -981,10 +982,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- ${JSON.stringify(cwd)}
cd -- "$OPENCODE_SHELL_CWD" || exit $?
unset OPENCODE_SHELL_CWD
eval ${JSON.stringify(input.command)}
`,
"pawwork",
"opencode",
],
},
cmd: { args: ["/c", input.command] },
Expand All @@ -1002,10 +1004,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
)

const env = {
...shellEnv.env,
TERM: "dumb",
...(shellName === "zsh" || shellName === "bash" ? { OPENCODE_SHELL_CWD: cwd } : {}),
}

const cmd = ChildProcess.make(sh, args, {
cwd,
extendEnv: true,
env: { ...shellEnv.env, TERM: "dumb" },
env,
stdin: "ignore",
forceKillAfter: "3 seconds",
})
Expand Down
Loading
Loading