Skip to content
Closed
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
2,017 changes: 2,017 additions & 0 deletions docs/fixes/subagent-fix-output-length.md

Large diffs are not rendered by default.

30 changes: 17 additions & 13 deletions packages/opencode/src/plugin/github-copilot/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,23 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
effort,
}
})
} else if (remote.capabilities.supports.max_thinking_budget) {
const max = remote.capabilities.supports.max_thinking_budget
variants["max"] = {
thinking: {
type: "enabled",
budgetTokens: max - 1,
},
}
variants["high"] = {
thinking: {
type: "enabled",
budgetTokens: Math.floor(max / 2),
},
} else if (remote.capabilities.supports.max_thinking_budget !== undefined) {
const remoteMaximum = remote.capabilities.supports.max_thinking_budget
const discoveredCap = Math.floor(remoteMaximum) - 1
const discoveredMin = Math.max(1, Math.ceil(remote.capabilities.supports.min_thinking_budget ?? 1))
if (discoveredCap >= discoveredMin) {
variants["max"] = {
thinking: {
type: "enabled",
budgetTokens: discoveredCap,
},
}
variants["high"] = {
thinking: {
type: "enabled",
budgetTokens: Math.min(discoveredCap, Math.max(discoveredMin, Math.floor(remoteMaximum / 2))),
},
}
}
}
}
Expand Down
47 changes: 37 additions & 10 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,37 @@ export const Model = Schema.Struct({
}).annotate({ identifier: "Model" })
export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>

function mergeModelVariants(
providerID: ProviderV2.ID,
base: NonNullable<Model["variants"]>,
configured: Record<string, Record<string, any>> | undefined,
): NonNullable<Model["variants"]> {
const discoveredCap =
providerID === ProviderV2.ID.githubCopilot && typeof base.max?.thinking?.budgetTokens === "number"
? base.max.thinking.budgetTokens
: undefined
const merged = mergeDeep(base, configured ?? {})
const result = mapValues(
pickBy(merged, (variant) => !variant.disabled),
(variant) => omit(variant, ["disabled"]),
) as NonNullable<Model["variants"]>

if (discoveredCap === undefined) return result

if (!result.max) result.max = omit(base.max, ["disabled"])
for (const [name, variant] of Object.entries(result)) {
const budget = variant.thinking?.budgetTokens
if (typeof budget !== "number") continue
const normalized = name === "max" ? discoveredCap : Math.min(budget, discoveredCap)
result[name] = mergeDeep(variant, {
thinking: {
budgetTokens: normalized,
},
})
}
return result
}

export const Info = Schema.Struct({
id: ProviderV2.ID,
name: Schema.String,
Expand Down Expand Up @@ -1492,11 +1523,11 @@ const layer = Layer.effect(
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
const catalogVariants =
providerID === ProviderV2.ID.githubCopilot && existingModel?.variants
? existingModel.variants
: ProviderTransform.variants(parsedModel)
parsedModel.variants = mergeModelVariants(ProviderV2.ID.make(providerID), catalogVariants, model.variants)
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
Expand Down Expand Up @@ -1626,11 +1657,7 @@ const layer = Layer.effect(

const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
const merged = mergeDeep(model.variants, configVariants)
model.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
model.variants = mergeModelVariants(providerID, model.variants, configVariants)
}
}

Expand Down
237 changes: 233 additions & 4 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,13 @@ function googleThinkingBudgetMax(apiId: string) {
return 24_576
}

function googleThinkingBudgetMin(apiId: string) {
const id = apiId.toLowerCase()
if (id.includes("flash-lite")) return 512
if (id.includes("pro") && !id.includes("flash")) return 128
return 1
}

// SAP's Zod schema drops unknown top-level keys; reasoning controls survive
// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs.
function wrapInSapModelParams(variants: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
Expand Down Expand Up @@ -674,6 +681,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (!model.capabilities.reasoning) return {}

const id = model.id.toLowerCase()
const catalogOutput = model.limit.output > 0 ? model.limit.output : OUTPUT_TOKEN_MAX
const glm52 = ["glm-5.2", "glm-5-2", "glm-5p2"].some(
(name) => id.includes(name) || model.api.id.toLowerCase().includes(name),
)
Expand Down Expand Up @@ -945,13 +953,13 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
high: {
thinking: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
budgetTokens: Math.min(16_000, Math.floor(catalogOutput / 2 - 1)),
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: Math.min(31_999, model.limit.output - 1),
budgetTokens: Math.min(31_999, catalogOutput - 1),
},
},
}
Expand Down Expand Up @@ -1342,8 +1350,229 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a
return { [key]: normalized }
}

export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
export function maxOutputTokens(model: Provider.Model, outputTokenMax?: number): number {
if (model.limit.output === 0) return outputTokenMax ?? OUTPUT_TOKEN_MAX
if (outputTokenMax !== undefined) return Math.min(model.limit.output, outputTokenMax)
if (model.capabilities.reasoning) return model.limit.output
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX)
}

const NUMERIC_REASONING_BUDGET_PATHS = [
["thinking", "budgetTokens"],
["thinking", "budget_tokens"],
["thinkingConfig", "thinkingBudget"],
["reasoningConfig", "budgetTokens"],
["modelParams", "thinking", "budget_tokens"],
["modelParams", "thinkingConfig", "thinkingBudget"],
] as const

function getPath(input: Record<string, any>, path: readonly string[]) {
let current: unknown = input
for (const key of path) {
if (!isPlainObject(current)) return undefined
current = current[key]
}
return current
}

function setPath(input: Record<string, any>, path: readonly string[], value: number) {
const result = { ...input }
let source: Record<string, unknown> = input
let target: Record<string, unknown> = result

for (const key of path.slice(0, -1)) {
const next = isPlainObject(source[key]) ? source[key] : {}
const cloned = { ...next }
target[key] = cloned
source = next
target = cloned
}
target[path[path.length - 1]] = value
return result
}

function reasoningBudgetBounds(model: Provider.Model) {
const id = `${model.id} ${model.api.id}`.toLowerCase()
if (model.providerID === "github-copilot") {
const maximum = model.variants?.max?.thinking?.budgetTokens
return {
kind: "copilot" as const,
minimum: undefined,
maximum: typeof maximum === "number" && Number.isFinite(maximum) ? maximum : undefined,
}
}

const anthropic =
model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
(model.api.npm === "@ai-sdk/gateway" && id.includes("anthropic")) ||
(model.api.npm === "@ai-sdk/amazon-bedrock" && id.includes("anthropic")) ||
(model.api.npm === "@jerome-benoit/sap-ai-provider-v2" && id.includes("anthropic"))
if (anthropic) {
return {
kind: "known" as const,
minimum: 1_024,
maximum: undefined,
}
}

if (id.includes("gemini") && id.includes("2.5")) {
return {
kind: "known" as const,
minimum: googleThinkingBudgetMin(id),
maximum: googleThinkingBudgetMax(id),
}
}

return {
kind: "unknown" as const,
minimum: undefined,
maximum: undefined,
}
}

function usesAnthropicMessagesTransport(model: Provider.Model) {
return model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic"
}

function reasoningBudgetError(input: {
model: Provider.Model
variant: string | undefined
maxOutputTokens: number
minimum: number | undefined
maximum: number | undefined
reason: string
}) {
return new Error(
[
"Invalid numeric reasoning budget:",
`provider=${input.model.providerID}`,
`model=${input.model.id}`,
`variant=${input.variant ?? "none"}`,
`maxOutputTokens=${input.maxOutputTokens}`,
`minimum=${input.minimum ?? "unknown"}`,
`maximum=${input.maximum ?? "unknown"}`,
input.reason,
].join(" "),
)
}

export function normalizeReasoningBudget(input: {
model: Provider.Model
variant: string | undefined
options: Record<string, any>
maxOutputTokens: number
}): Record<string, any> {
const bounds = reasoningBudgetBounds(input.model)
const minimum = bounds.minimum
const maximum = bounds.maximum
const fail = (reason: string): never => {
throw reasoningBudgetError({
model: input.model,
variant: input.variant,
maxOutputTokens: input.maxOutputTokens,
minimum,
maximum,
reason,
})
}

if (!Number.isInteger(input.maxOutputTokens) || input.maxOutputTokens <= 0) {
fail("the output envelope must be a positive integer")
}
if (minimum !== undefined && maximum !== undefined && maximum < minimum) {
fail("the provider reasoning bounds are contradictory")
}

const reserve = Math.max(4_096, Math.ceil(input.maxOutputTokens * 0.1))
const safeCap = input.maxOutputTokens - reserve
const wireCap = Math.min(input.maxOutputTokens - 1, maximum ?? Number.POSITIVE_INFINITY)
let result = { ...input.options }

for (const path of NUMERIC_REASONING_BUDGET_PATHS) {
const existing = getPath(input.options, path)
if (typeof existing !== "number") continue
if (!Number.isFinite(existing) || !Number.isInteger(existing) || existing <= 0) {
fail(`the value at ${path.join(".")} must be a positive integer`)
}

if (bounds.kind === "copilot" && maximum !== undefined) {
if (input.variant === "max") {
if (safeCap < maximum || maximum >= input.maxOutputTokens) {
fail("the request envelope would require lowering the discovered Copilot maximum without a known minimum")
}
result = setPath(result, path, maximum)
continue
}

const capped = Math.min(existing, maximum)
if (capped >= input.maxOutputTokens || capped > safeCap) {
fail("the request envelope would require lowering a Copilot budget without a known minimum")
}
result = setPath(result, path, capped)
continue
}

if (wireCap < (minimum ?? 1)) {
fail("the output envelope cannot contain a legal reasoning budget and a visible token")
}

if (bounds.kind === "known" && input.variant === "max") {
const target = safeCap >= (minimum ?? 1) ? Math.min(safeCap, wireCap) : Math.min(minimum ?? 1, wireCap)
result = setPath(result, path, target)
continue
}

if (minimum !== undefined && existing < minimum) {
fail(`the existing value at ${path.join(".")} is below the provider minimum`)
}

const safeUpper = safeCap >= (minimum ?? 1) ? Math.min(safeCap, wireCap) : Math.min(minimum ?? 1, wireCap)
const normalized = Math.min(existing, safeUpper)
if (normalized <= 0 || normalized >= input.maxOutputTokens || (minimum !== undefined && normalized < minimum)) {
fail(`the existing value at ${path.join(".")} cannot be made legal by clamping downward`)
}
result = setPath(result, path, normalized)
}

if (
usesAnthropicMessagesTransport(input.model) &&
input.options.thinking?.type === "enabled" &&
input.options.thinking.budgetTokens === undefined &&
input.maxOutputTokens <= 1_024
) {
fail("the output envelope cannot contain the Anthropic SDK implicit reasoning budget and a visible token")
}

return result
}

export function transportMaxOutputTokens(input: {
model: Provider.Model
options: Record<string, any>
maxOutputTokens: number
}): number {
const anthropicBudget = usesAnthropicMessagesTransport(input.model)
? input.options.thinking?.type === "enabled" && typeof input.options.thinking.budgetTokens === "number"
? input.options.thinking.budgetTokens
: input.options.thinking?.type === "enabled" && input.options.thinking.budgetTokens === undefined
? 1_024
: undefined
: undefined
const bedrockBudget =
input.model.api.npm === "@ai-sdk/amazon-bedrock" &&
input.model.api.id.toLowerCase().includes("anthropic") &&
input.options.reasoningConfig?.type === "enabled" &&
typeof input.options.reasoningConfig.budgetTokens === "number"
? input.options.reasoningConfig.budgetTokens
: undefined
const budget = anthropicBudget ?? bedrockBudget
if (budget === undefined) return input.maxOutputTokens

// These AI SDK transports add the numeric thinking budget to their standardized
// maxOutputTokens input before writing max_tokens/maxTokens. Subtract it here so
// the provider's final total stays inside the core output envelope.
return input.maxOutputTokens - budget
}

type JsonRecord = Record<string, unknown>
Expand Down
Loading
Loading