Compare commits

..
4 Commits
Author SHA1 Message Date
Aiden Cline e8a70dd5d2 fix(core): cap the summary output limit at 32k 2026-09-26 21:21:47 -05:00
Aiden Cline a3242c6da6 fix(core): let the summary use the whole compaction reserve 2026-09-26 20:36:46 -05:00
Aiden Cline 1b91a0e86c fix(core): reserve room for the reply and the summary before compacting
Live runs against Anthropic, MiniMax, Kimi, DeepSeek, and GLM showed the
summary request's output limit collapsing to the 1k floor at the threshold:
it was sized from the request as prepared, which overshoots the budget the
request is later shrunk to. A summary longer than that failed the compaction.

Compaction now keeps a reserve of max(10% of the window, 16k) free, so the
last reply before compaction and the summary itself always have that much
room. The summary's output limit is a flat 16k. The output limit for normal
requests fits the model's limit to the room the prompt leaves, padding only
the estimated part by 5%; the 256k cap and the 4k slack are gone.
2026-09-26 20:13:08 -05:00
Aiden Cline f6cb97672e feat(core): fit output limits to the context window 2026-09-26 00:06:15 -05:00
25 changed files with 357 additions and 356 deletions
-2
View File
@@ -119,7 +119,6 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/pty": "0.1.13",
@@ -136,7 +135,6 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"picocolors": "1.1.1",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
"x86_64-linux": "sha256-9gJjhes2ueYckAgdeGlPwZcaIDdwB3ZnqK/XHHXhWNs=",
"aarch64-linux": "sha256-Sy5YXYM9tKevIITdV++bP35SJNaFCQVKwlNJRbWsD1Q=",
"aarch64-darwin": "sha256-wiXHjKXm2VIFvalwITpSiRHaFZEWc8UJIqjQyc/0f0s=",
"x86_64-darwin": "sha256-r/mnhdNbnPIJOY3qvtuY6GQ7ed1Nauq65X+8uERhdP8="
}
}
-2
View File
@@ -25,7 +25,6 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode/client": "workspace:*",
@@ -42,7 +41,6 @@
"effect": "catalog:",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"picocolors": "1.1.1",
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+4 -6
View File
@@ -142,10 +142,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
}),
Spec.make("auth", {
description: "manage integrations and credentials",
description: "manage AI providers and credentials",
commands: [
Spec.make("list", {
description: "list integrations and credentials",
description: "list providers and credentials",
params: {
...ServerParams,
format: Flag.choice("format", ["default", "json"]).pipe(
@@ -155,7 +155,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
},
}),
Spec.make("login", {
description: "connect an integration",
description: "log in to a provider",
params: {
...ServerParams,
target: Argument.string("target").pipe(
@@ -228,9 +228,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
Spec.make("auth", {
description: "Authenticate with an OAuth-capable remote MCP server",
params: {
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional),
},
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
}),
Spec.make("logout", {
description: "Remove stored OAuth credentials for an MCP server",
@@ -1,9 +1,8 @@
import { intro, log, outro, select, spinner, text } from "@clack/prompts"
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
import { Effect, Option } from "effect"
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm, secret } from "./form"
import {
@@ -22,8 +21,10 @@ const integrationPriority = new Map([
["opencode", 1],
["openai", 2],
["github-copilot", 3],
["anthropic", 4],
["google", 5],
["google", 4],
["anthropic", 5],
["openrouter", 6],
["vercel", 7],
])
export default Runtime.handler(
@@ -73,35 +74,30 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien
}
const integrations = yield* loadIntegrations(client)
if (target) return yield* resolveIntegration(integrations, target)
const choices = loginChoices(integrations)
if (choices.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
const id = yield* prompt<string>(() => selectIntegration(choices))
return yield* resolveIntegration(integrations, id)
})
export function loginChoices(integrations: IntegrationInfo[]): IntegrationChoice[] {
return integrations
const available = integrations
.filter((integration) => connectMethods(integration).length > 0)
.toSorted(
(a, b) =>
Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") ||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
)
.map((integration) => ({
value: integration.id,
label: integration.name,
category:
integration.metadata?.source === "mcp"
? "MCP"
: integrationPriority.has(integration.id)
? "Popular"
: "Services",
connected: integration.connections.length > 0,
}))
}
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
const id = yield* prompt<string>(() =>
autocomplete({
message: "Select integration",
maxItems: 8,
options: available.map((integration) => {
const option = { value: integration.id, label: integration.name, hint: integration.id }
if (integration.connections.length > 0) return { ...option, hint: "connected" }
if (integration.id === "opencode") return { ...option, hint: "recommended" }
return option
}),
}),
)
return yield* resolveIntegration(available, id)
})
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
if (target) return yield* resolveMethod(methods, target)
@@ -147,18 +143,17 @@ const keyLogin = Effect.fn("cli.auth.login.key")(function* (
)
})
export const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
client: OpenCodeClient,
integration: IntegrationInfo,
method: Extract<ConnectMethod, { type: "oauth" }>,
answer?: FormAnswer,
label?: string,
) {
const progress = spinner()
progress.start("Starting authorization...")
const started = yield* request((signal) =>
client.integration.oauth.connect(
{ integrationID: integration.id, methodID: method.id, answer, label, location },
{ integrationID: integration.id, methodID: method.id, answer, location },
{ signal },
),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
@@ -195,14 +190,16 @@ export const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
return
}
// Clack's spinner captures Ctrl+C and exits the process directly, which would skip the finalizer that
// cancels the attempt. Waits that can last minutes use plain log lines so Ctrl+C interrupts normally.
log.step("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID)
const waiting = spinner()
waiting.start("Waiting for authorization...")
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
)
if (status.status === "complete") {
log.success(`Connected to ${integration.name}`)
waiting.stop(`Connected to ${integration.name}`)
return
}
waiting.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authorization expired"))
})
@@ -229,21 +226,14 @@ const commandLogin = Effect.fn("cli.auth.login.command")(function* (
),
).pipe(Effect.ignore),
)
progress.stop("Authentication command started")
// The status message accumulates the command's stderr; print each completed line once.
let printed = 0
log.step("Waiting for authentication command...")
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) => {
const end = message.lastIndexOf("\n") + 1
if (end <= printed) return
const output = message.slice(printed, end).trim()
printed = end
if (output) log.message(output)
})
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
progress.message(message.trim() || "Waiting for authentication command..."),
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
if (status.status === "complete") {
log.success(`Connected to ${integration.name}`)
progress.stop(`Connected to ${integration.name}`)
return
}
progress.stop("Authentication failed", 1)
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
yield* Effect.fail(new Error("Authentication expired"))
})
+53 -97
View File
@@ -1,109 +1,65 @@
import { confirm, intro, log, outro } from "@clack/prompts"
import { Effect, Option } from "effect"
import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpServer } from "@opencode/client"
import { EOL } from "node:os"
import { Effect } from "effect"
import {
OpenCode,
type IntegrationAttemptStatus,
type IntegrationOAuthMethod,
type OpenCodeClient,
} from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm } from "../auth/form"
import { oauthLogin } from "../auth/login"
import { loadIntegrations, request } from "../auth/shared"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.auth,
Effect.fn("cli.mcp.auth")((input) => authenticate(Option.getOrUndefined(input.name)).pipe(handlePromptErrors)),
Effect.fn("cli.mcp.auth")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
if (!integration)
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const method = integration.methods.find(
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
)
if (!method)
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() =>
client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }),
)
const attempt = started.data
if (attempt.mode === "code")
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
const result = yield* poll(client, integration.id, attempt.attemptID)
if (result.status === "complete") {
process.stdout.write(`Authenticated with ${input.name}` + EOL)
return
}
const reason = result.status === "failed" ? `: ${result.message}` : ""
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
}),
)
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
intro("Authenticate an MCP server")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integrations = yield* loadIntegrations(client)
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
const choices = mcpAuthChoices(servers.data, integrations)
if (!name && choices.length === 0) {
log.warn("No OAuth-capable MCP servers configured")
log.info(
`Remote MCP servers support OAuth by default. Add one with \`opencode mcp add\` or in opencode.json:\n${exampleConfig}`,
)
outro("Done")
return
}
const server = name ? servers.data.find((item) => item.name === name) : undefined
if (name && !server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
const integrationID = server
? server.integrationID
: yield* prompt<string>(() => selectIntegration(choices, "MCP server"))
const integration = integrations.find((item) => item.id === integrationID)
const method = integration?.methods.find(
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
)
if (!integration || !method)
return yield* Effect.fail(new Error(`MCP server "${name}" is not an OAuth-capable remote server`))
if (integration.connections.length > 0) {
const status = servers.data.find((item) => item.integrationID === integration.id)?.status.status
if (status === "needs_auth") log.warn(`${integration.name} has expired credentials. Re-authenticating...`)
if (status !== "needs_auth" && process.stdin.isTTY && process.stdout.isTTY) {
const again = yield* prompt<boolean>(() =>
confirm({ message: `${integration.name} already has valid credentials. Re-authenticate?` }),
)
if (!again) {
outro("Cancelled")
return
}
const poll = (
client: OpenCodeClient,
integrationID: string,
attemptID: string,
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
Effect.gen(function* () {
const status = yield* Effect.promise(() =>
client.integration.oauth.status({ integrationID, attemptID, location }),
).pipe(Effect.map((result) => result.data))
if (status.status === "pending") {
yield* Effect.sleep("1 second")
return yield* poll(client, integrationID, attemptID)
}
}
// Re-authenticating replaces the previous sign-in rather than adding an account. The new credential
// keeps the active one's label, and the old ones are only removed once it is stored, so a failed
// attempt keeps them.
const previous = integration.connections.filter((connection) => connection.type === "credential")
yield* oauthLogin(client, integration, method, yield* answerForm(method.form), previous[0]?.label)
yield* Effect.forEach(
previous,
(connection) => request((signal) => client.credential.remove({ credentialID: connection.id }, { signal })),
{ discard: true },
)
outro("Done")
})
const exampleConfig = `
"mcp": {
"my-server": {
"type": "remote",
"url": "https://example.com/mcp"
}
}`
// Choices carry the server-owned integration ID so provider integrations with colliding names never match.
export function mcpAuthChoices(servers: McpServer[], integrations: IntegrationInfo[]): IntegrationChoice[] {
const byID = new Map(integrations.map((integration) => [integration.id, integration]))
return servers
.flatMap((server) => {
const integration = server.integrationID ? byID.get(server.integrationID) : undefined
if (!integration?.methods.some((method) => method.type === "oauth")) return []
return [
{
value: integration.id,
label: server.name,
category: "MCP" as const,
connected: integration.connections.length > 0,
hint: statusHint(server.status),
},
]
})
.toSorted((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value))
}
function statusHint(status: McpServer["status"]) {
if (status.status === "needs_auth") return "needs authentication"
if (status.status === "failed" || status.status === "disabled") return status.status
return undefined
}
return status
})
-67
View File
@@ -1,67 +0,0 @@
import { AutocompletePrompt } from "@clack/core"
import { S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbol } from "@clack/prompts"
import color from "picocolors"
export type IntegrationChoice = {
value: string
label: string
category: "MCP" | "Popular" | "Services"
connected: boolean
hint?: string
}
export async function selectIntegration(choices: IntegrationChoice[], kind = "integration") {
const result = await new AutocompletePrompt<IntegrationChoice>({
options: choices,
filter: (search, choice) =>
[choice.label, choice.value, choice.category].some((value) => value.toLowerCase().includes(search.toLowerCase())),
validate: (value) => (value ? undefined : `Select an ${kind}`),
render() {
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} Select ${kind}`
if (this.state === "submit") {
const choice = choices.find((item) => item.value === this.value)
return `${title}\n${color.gray(S_BAR)} ${color.dim(choice?.label ?? "")}`
}
if (this.state === "cancel")
return `${title}\n${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.userInput))}`
// Leave room for the category headings as well as Clack's title and footer.
const maxItems = Math.min(8, Math.max(2, (process.stdout.rows ?? 24) - 14 - Number(this.state === "error")))
const compact = (process.stdout.rows ?? 24) < 18
const start = Math.min(
Math.max(0, this.cursor - Math.min(2, maxItems - 1)),
Math.max(0, this.filteredOptions.length - maxItems),
)
const visible = this.filteredOptions.slice(start, start + maxItems)
const rows = visible.flatMap((choice, index) => [
...(index === 0 || visible[index - 1].category !== choice.category
? [...(compact ? [] : [`${color.cyan(S_BAR)} `]), `${color.cyan(S_BAR)} ${color.bold(choice.category)}`]
: []),
`${color.cyan(S_BAR)} ${start + index === this.cursor ? color.green(S_RADIO_ACTIVE) : color.dim(S_RADIO_INACTIVE)} ${
start + index === this.cursor ? choice.label : color.dim(choice.label)
}${choice.connected ? ` ${color.green("✓")}` : ""}${choice.hint ? ` ${color.dim(`(${choice.hint})`)}` : ""}`,
])
return [
title,
`${color.cyan(S_BAR)} ${color.dim("Search:")} ${this.isNavigating ? color.dim(this.userInput) : this.userInputWithCursor}`,
...(visible.length === 0 && this.userInput
? [`${color.cyan(S_BAR)} ${color.yellow(`No ${kind}s found`)}`]
: []),
...(this.state === "error" && visible.length > 0
? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`]
: []),
...(start > 0 ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []),
...rows,
...(start + maxItems < this.filteredOptions.length ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []),
`${color.cyan(S_BAR)} ${color.dim(
(process.stdout.columns ?? 80) < 50
? "↑/↓ navigate • Enter select"
: "↑/↓ to select • Enter: confirm • Type: to search",
)}`,
color.cyan(S_BAR_END),
].join("\n")
},
}).prompt()
if (typeof result === "string" || typeof result === "symbol") return result
throw new Error(`No ${kind} selected`)
}
-1
View File
@@ -22,7 +22,6 @@ export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string)
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.onInterrupt(() => Effect.sync(() => cancel("Cancelled"))),
Effect.catchIf(
(error) => error === cancelled,
() =>
@@ -1,30 +0,0 @@
import { expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode/client"
import { loginChoices } from "../src/commands/handlers/auth/login"
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [{ type: "key" }],
connections: [],
...value,
})
test("groups the CLI choices like /connect while keeping stable login IDs", () => {
expect(
loginChoices([
integration({ id: "mistral", name: "Mistral" }),
integration({ id: "openai", name: "OpenAI" }),
integration({ id: "linear", name: "Linear", metadata: { source: "mcp" } }),
integration({ id: "github", name: "GitHub", metadata: { source: "mcp" } }),
integration({ id: "opencode", name: "OpenCode Console" }),
integration({ id: "opencode-go", name: "OpenCode Go", connections: [{ type: "env", name: "GO_KEY" }] }),
integration({ id: "unused", name: "Unused", methods: [{ type: "env", names: ["UNUSED_KEY"] }] }),
]),
).toEqual([
{ value: "github", label: "GitHub", category: "MCP", connected: false },
{ value: "linear", label: "Linear", category: "MCP", connected: false },
{ value: "opencode-go", label: "OpenCode Go", category: "Popular", connected: true },
{ value: "opencode", label: "OpenCode Console", category: "Popular", connected: false },
{ value: "openai", label: "OpenAI", category: "Popular", connected: false },
{ value: "mistral", label: "Mistral", category: "Services", connected: false },
])
})
+6 -10
View File
@@ -20,12 +20,12 @@ describe("auth command", () => {
expect(auth.stdout).toContain("list")
expect(auth.stdout).toContain("login")
expect(auth.stdout).toContain("logout")
expect(auth.stdout).toContain("manage integrations and credentials")
expect(auth.stdout).toContain("list integrations and credentials")
expect(auth.stdout).toContain("connect an integration")
expect(auth.stdout).toContain("manage AI providers and credentials")
expect(auth.stdout).toContain("list providers and credentials")
expect(auth.stdout).toContain("log in to a provider")
expect(auth.stdout).toContain("log out of a saved account")
expect(auth.stdout).toContain("switch the active account for an integration")
expect(auth.stdout).not.toMatch(/^ connect\s/m)
expect(auth.stdout).not.toContain("connect")
expect(list.exitCode).toBe(0)
expect(list.stdout).toContain("opencode auth list [flags]")
expect(list.stdout).toContain("--format")
@@ -216,8 +216,7 @@ describe("auth command", () => {
expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` })
})
test("reports OAuth status polling failures and cancels the attempt", async () => {
let cancelled = false
test("settles the OAuth spinner when status polling fails", async () => {
using server = authServer((request, url) => {
if (url.pathname === "/api/integration") {
return Response.json(
@@ -246,7 +245,6 @@ describe("auth command", () => {
return new Response("Unavailable", { status: 500 })
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
cancelled = true
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
@@ -254,10 +252,8 @@ describe("auth command", () => {
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Waiting for authorization...")
expect(result.stdout).toContain("UnexpectedStatus: 500")
expect(result.stdout).toContain("Authentication failed")
expect(result.stdout).toContain("Failed")
expect(cancelled).toBe(true)
expect(result.stdout).not.toContain("\n at ")
})
@@ -1,62 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
import type { IntegrationInfo, McpServer } from "@opencode/client"
import { mcpAuthChoices } from "../src/commands/handlers/mcp/auth"
const server = (
name: string,
integrationID?: string,
status: McpServer["status"] = { status: "pending" },
): McpServer => ({ name, integrationID, status })
const integration = (id: string, methods: IntegrationInfo["methods"], connected = false): IntegrationInfo => ({
id,
name: id,
methods,
connections: connected ? [{ type: "credential", method: "oauth", id: "cred_1", label: "Work" }] : [],
})
test("offers only OAuth-capable MCP servers by their server identity", () => {
expect(
mcpAuthChoices(
[
server("Linear", "mcp_linear", { status: "needs_auth", error: "expired" }),
server("Local"),
server("API key only", "mcp_key"),
server("GitHub", "mcp_github"),
server("Sentry", "mcp_sentry", { status: "failed", error: "boom" }),
server("Unresolved", "mcp_missing"),
],
[
integration("mcp_linear", [{ type: "oauth", id: "login", label: "Linear" }], true),
integration("mcp_github", [{ type: "oauth", id: "login", label: "GitHub" }]),
integration("mcp_sentry", [{ type: "oauth", id: "login", label: "Sentry" }]),
integration("mcp_key", [{ type: "key" }]),
integration("Linear", [{ type: "oauth", id: "login", label: "A provider with a colliding name" }]),
],
),
).toEqual([
{ value: "mcp_github", label: "GitHub", category: "MCP", connected: false, hint: undefined },
{ value: "mcp_linear", label: "Linear", category: "MCP", connected: true, hint: "needs authentication" },
{ value: "mcp_sentry", label: "Sentry", category: "MCP", connected: false, hint: "failed" },
])
})
test("mcp auth accepts an optional server name and rejects no-name noninteractive calls before connecting", async () => {
const cli = (args: string[]) =>
Bun.spawn([process.execPath, "run", "src/index.ts", "mcp", "auth", ...args], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const help = cli(["--help"])
expect(await new Response(help.stdout).text()).toContain("opencode mcp auth [flags] [<name>]")
expect(await help.exited).toBe(0)
const missing = cli([])
expect(await new Response(missing.stdout).text()).toContain(
"Pass an MCP server name when running without an interactive terminal",
)
expect(await new Response(missing.stderr).text()).toBe("")
expect(await missing.exited).toBe(1)
})
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionRequest } from "@opencode/plugin/effect/session"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
@@ -307,6 +308,13 @@ export const OpenAIPlugin = define({
}),
{ providerID: Provider.ID.openai },
)
// The ChatGPT backend rejects a requested output limit, and OpenAI counts one against rate limits.
const omitOutputLimit = (evt: SessionRequest) =>
Effect.sync(() => {
delete evt.options.maxTokens
})
for (const name of ["context", "compaction"] as const)
yield* ctx.session.hook(name, omitOutputLimit, { providerID: Provider.ID.openai })
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
+35 -8
View File
@@ -90,6 +90,8 @@ type Streamed = {
const NOTHING_TO_COMPACT: Failure = { error: { type: "compaction.unavailable", message: "Nothing to compact yet" } }
/** After each "too long" rejection, the next attempt aims at this share of the first rejected request's size. */
const SHRINK_STEPS = [0.7, 0.5, 0.35]
// The least of the window kept free for the last reply before compaction and for the summary itself.
const RESERVE_MIN = 16_000
/** A common window size, assumed for the compaction request when the model's window is unknown. */
const UNKNOWN_WINDOW = 200_000
const TOOL_OUTPUT_MAX_CHARS = 1_250
@@ -265,7 +267,7 @@ export const layer = Layer.effect(
const prompt = buildPrompt(previous !== undefined, previous?.summary.includes(LEGACY_HEADING) ?? false)
const headings = SUMMARY_TEMPLATE.split("\n").filter((line) => line.startsWith("##"))
const filled = (text: string) => text.split("\n").some((line) => headings.includes(line.trim()))
const prepared = yield* prepare(context, split.older)
const prepared = yield* prepare(context, split.older, budget)
// Hooks saw the request without the summary prompt, so it is appended here. A reply that ignores the
// template gets one reminder before it counts as a failure.
@@ -313,7 +315,7 @@ export const layer = Layer.effect(
if (!context.messages.some(messageToText)) return yield* Effect.fail(NOTHING_TO_COMPACT)
const unsupported = (message: string) =>
Effect.fail<Failure>({ error: { type: "provider.unsupported-operation", message } })
const prepared = yield* prepare(context, context.messages, "session")
const prepared = yield* prepare(context, context.messages, budget, "session")
// History is selected before request hooks, so a hook that reroutes the request cannot be honored here.
const provenance = SessionProviderContext.provenance(context.model)
@@ -550,10 +552,18 @@ export const layer = Layer.effect(
)
}
/** The conversation as the runner would send it, after request hooks. */
/**
* The conversation as the runner would send it, after request hooks.
*
* The output limit leaves room for `budget`, the most `deliver` sends. The request prepared here can be larger
* when the conversation overshot the threshold, and is only shrunk to fit after hooks have seen it, so sizing the
* output to it would leave next to no room. A prompt the estimate undersells is rejected and shrunk like any
* other.
*/
const prepare = (
context: SessionContext.Loaded,
messages: ReadonlyArray<SessionMessage.Info>,
budget: number,
webSocket?: "session",
) => {
const base = transcript(context, messages)
@@ -565,6 +575,7 @@ export const layer = Layer.effect(
system: base.system,
messages: base.messages,
webSocket,
inputTokens: { measured: budget, estimated: 0 },
})
}
@@ -843,6 +854,12 @@ export const recentUserMessages = (
}
export const estimateContext = (context: SessionContext.Loaded) => {
const prompt = estimatePrompt(context)
return prompt.measured + prompt.estimated
}
/** The prompt size: `measured` is what the provider reported at the latest response, `estimated` is the text since. */
export const estimatePrompt = (context: SessionContext.Loaded) => {
const anchorIndex = context.messages.findLastIndex((message) => hasMeasuredPrompt(message, context.model.ref))
const anchor = context.messages[anchorIndex]
const base = transcript(context, context.messages.slice(Math.max(0, anchorIndex)))
@@ -856,20 +873,30 @@ export const estimateContext = (context: SessionContext.Loaded) => {
const unmeasured = sent.filter((message) => message.role !== "assistant" || message.id !== anchor?.id)
if (anchor?.type !== "assistant" || !anchor.tokens)
return estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured })
return {
measured: 0,
estimated: estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured }),
}
const tokens = anchor.tokens
const measured = tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning
return measured + unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0)
return {
measured: tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning,
estimated: unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0),
}
}
/** The largest request the model takes while leaving room for its reply. */
/**
* The largest request the model takes while leaving room for its reply: 10% of the window, or `RESERVE_MIN` when that
* is more. The summary request is capped at the same size, so its output limit is whatever the reserve leaves. A window
* too small to give up `RESERVE_MIN` keeps 10%.
*/
const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: number | undefined) => {
// Unknown limits are reported as 0. An unknown input limit falls back to the context window; with no window at
// all, only a provider rejection can limit the request.
const window = limit.input || limit.context
if (window <= 0) return Number.POSITIVE_INFINITY
return buffer === undefined ? Math.floor(window * 0.9) : window - buffer
if (buffer !== undefined) return window - buffer
return window - Math.max(Math.floor(window * 0.1), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
}
/**
+35 -1
View File
@@ -44,6 +44,14 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
// Used when the catalog has no output limit for the model.
const OUTPUT_TOKEN_FALLBACK = 32_000
// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates.
const SUMMARY_OUTPUT_MAX = 32_000
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
const ESTIMATE_ERROR = 0.05
// Never ask for less; only reachable with automatic compaction off, since it keeps the window from filling this far.
const OUTPUT_TOKEN_MIN = 1_024
/** Tool errors, plus the user declining a permission or dismissing a question. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -69,6 +77,21 @@ export interface Input {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/** Only the durable runner may use a stateful WebSocket. */
readonly webSocket?: "session"
/** Prompt size, measured by the provider or estimated. The default output limit leaves room for it. */
readonly inputTokens?: { readonly measured: number; readonly estimated: number }
}
/** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */
export const outputLimit = (
limit: Model.Info["limit"],
kind: "primary" | "compaction",
inputTokens?: Input["inputTokens"],
) => {
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model
if (inputTokens === undefined || limit.context <= 0) return requested
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room))
}
export const baseTranscript = (input: {
@@ -218,8 +241,19 @@ export const layer = Layer.effect(
const given = new Map(
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
)
// Hooks see the default output limit and may change or remove it. Titles and generate keep the provider default,
// because their reasoning is hard to budget.
const shaped = yield* shape(
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
{
sessionID: session.id,
model: model.ref,
system: input.system,
messages: input.messages,
options:
kind === "primary" || kind === "compaction"
? { maxTokens: outputLimit(model.limit, kind, input.inputTokens) }
: {},
},
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
)
// Match by identity first, then by key. Entries matching neither were invented by a
+1
View File
@@ -240,6 +240,7 @@ const layer = Layer.effect(
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
toolChoice: stepLimitReached ? "none" : undefined,
webSocket: "session",
inputTokens: SessionCompaction.estimatePrompt(loaded),
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
@@ -216,6 +216,31 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("omits the default output limit from OpenAI steps and compaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const maxTokens = (providerID: Provider.ID) =>
Effect.gen(function* () {
const draft = {
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
system: [],
messages: [],
options: { maxTokens: 128_000 },
}
const events = [
yield* hooks.trigger("session", "context", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
yield* hooks.trigger("session", "compaction", { ...draft, agent: Agent.ID.make("build"), tools: {} }),
]
return events.map((event) => event.options.maxTokens)
})
expect(yield* maxTokens(Provider.ID.openai)).toEqual([undefined, undefined])
expect(yield* maxTokens(Provider.ID.azure)).toEqual([128_000, 128_000])
}),
)
it.effect("selects WebSocket only from explicit policy", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { GenerationOptions, LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -212,14 +212,15 @@ it.effect("auto compaction estimates current content against the buffered prompt
expect(yield* due(native(244_800))).toBe(true)
expect(yield* due(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
// The summary's 16k output limit is more than 10% of a 100k window, so it sets the ceiling.
const contextLimited = { context: 100_000, output: 10_000 }
expect(yield* due(input(89_999, contextLimited))).toBe(false)
expect(yield* due(input(90_000, contextLimited))).toBe(true)
expect(yield* due(input(83_999, contextLimited))).toBe(false)
expect(yield* due(input(84_000, contextLimited))).toBe(true)
// The reply limit does not lower the ceiling.
const outputLimited = { context: 100_000, output: 30_000 }
expect(yield* due(input(89_999, outputLimited))).toBe(false)
expect(yield* due(input(90_000, outputLimited))).toBe(true)
expect(yield* due(input(83_999, outputLimited))).toBe(false)
expect(yield* due(input(84_000, outputLimited))).toBe(true)
const assistant = input(89_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
@@ -452,7 +453,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
@@ -154,3 +154,62 @@ describe("SessionModelRequest HTTP hooks", () => {
}),
)
})
describe("SessionModelRequest output limit", () => {
const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] }
it.effect("defaults the output limit on primary and compaction requests", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
const large = {
...input,
model: SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "large-output", provider: "test" }), {
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 1_000_000, output: 384_000 },
}),
}
const maxTokens = (prepared: SessionModelRequest.Prepared<unknown>) => prepared.request.generation?.maxTokens
expect(maxTokens(yield* requests.primary(large))).toBe(384_000)
expect(maxTokens(yield* requests.compaction(large))).toBe(32_000)
// 200k window − 170k measured − 8k estimated with 5% padding
const inputTokens = { measured: 170_000, estimated: 8_000 }
expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(21_600)
expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(21_600)
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
// Provider plugins that remove the default limit only hook `context` and `compaction`. If titles or generate get a
// default, also hook `title` and `generate` in: the OpenAI plugin (`omitOutputLimit`), whose ChatGPT backend
// rejects any requested limit.
it.effect("sends no output limit for titles and generate by default", () =>
Effect.gen(function* () {
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.title(input)).request.generation?.maxTokens).toBeUndefined()
expect((yield* requests.generate(input)).request.generation?.maxTokens).toBeUndefined()
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("lets hooks change or remove the default output limit", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: Array<number | undefined> = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.options.maxTokens)
delete event.options.maxTokens
}),
)
yield* hooks.register("session", "title", (event) =>
Effect.sync(() => {
event.options.maxTokens = 100
}),
)
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
expect((yield* requests.primary(input)).request.generation).toBeUndefined()
expect((yield* requests.title(input)).request.generation?.maxTokens).toBe(100)
expect(seen).toEqual([32_000])
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
})
@@ -1,9 +1,40 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart, Media } from "@opencode/ai"
import { boundImages, unsupportedParts } from "@opencode/core/session/model-request"
import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
describe("SessionModelRequest.outputLimit", () => {
test("requests the catalog output limit, at most 32k for a summary", () => {
expect(outputLimit({ context: 1_000_000, output: 128_000 }, "primary")).toBe(128_000)
expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, "primary")).toBe(1_048_576)
expect(outputLimit({ context: 1_000_000, output: 128_000 }, "compaction")).toBe(32_000)
expect(outputLimit({ context: 200_000, output: 8_000 }, "compaction")).toBe(8_000)
})
test("falls back to 32k when the catalog has no output limit", () => {
expect(outputLimit({ context: 200_000, output: 0 }, "primary")).toBe(32_000)
})
test("fits the limit to the room the prompt leaves in the context window", () => {
const limit = { context: 1_000_000, output: 128_000 }
expect(outputLimit(limit, "primary", { measured: 50_000, estimated: 0 })).toBe(128_000)
expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 0 })).toBe(100_000)
// Estimated text counts 5% extra, so 40k estimated takes 42k of the room.
expect(outputLimit(limit, "primary", { measured: 900_000, estimated: 40_000 })).toBe(58_000)
})
test("keeps a minimum limit when the prompt nearly fills the context window", () => {
const prompt = { measured: 199_000, estimated: 0 }
expect(outputLimit({ context: 200_000, output: 64_000 }, "primary", prompt)).toBe(1_024)
expect(outputLimit({ context: 200_000, output: 512 }, "primary", prompt)).toBe(512)
})
test("ignores the prompt size when the context window is unknown", () => {
expect(outputLimit({ context: 0, output: 32_000 }, "primary", { measured: 500_000, estimated: 0 })).toBe(32_000)
})
})
describe("SessionModelRequest.unsupportedParts", () => {
test("replaces unsupported user media with a visible error", () => {
const messages = unsupportedParts(
+52 -1
View File
@@ -126,6 +126,8 @@ const fullOutputModel = testModel("full-output", { context: 262_144, output: 262
const unknownContextModel = testModel("unknown-context", { context: 0, output: 32_000 })
const undersizedContextModel = testModel("undersized-context", { context: 1, output: 1_000 })
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
const smallWindowModel = testModel("small-window", { context: 64_000, output: 16_000 })
test("calculates step cost using the matching context tier", () => {
expect(
@@ -3205,7 +3207,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(s.resume)).toMatchObject({ _tag: "Failure" })
expect(s.requests).toHaveLength(1)
expect(s.requests[0]?.generation).toBeUndefined()
expect(s.requests[0]?.generation?.maxTokens).toBe(50)
expect(yield* s.context).toContainEqual(
expect.objectContaining({
type: "compaction",
@@ -3410,6 +3412,55 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("fits the output limit to the prompt size", function* (s) {
s.currentModel = fittedOutputModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-fitted-first", 50_000))
yield* s.runPrompt("Earlier question")
yield* s.llm.push(TestLLM.text("Continued", "text-fitted-final"))
yield* s.runPrompt("Continue")
expect(s.requests[0]?.generation?.maxTokens).toBe(64_000)
expect(s.requests[1]?.generation?.maxTokens).toBeLessThan(100_000 - 50_000)
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 200)
})
scenario("gives the summary its full output limit when the conversation overshot the threshold", function* (s) {
// The conversation overshot the threshold, so the prepared summary request exceeds the budget and `deliver`
// shrinks it before sending. The output limit must follow the budget, not the oversized prepared request.
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-budget-first", 185_000))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(
TestLLM.text("## Objective\n- Preserve the task", "text-budget-summary"),
TestLLM.text("Continued", "text-budget-final"),
)
yield* s.runPrompt("Recent request ".repeat(400))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
// The summary may use the whole 20k reserve of a 200k window.
expect(s.requests[0]?.generation?.maxTokens).toBe(20_000)
expect(s.requests[1]?.generation?.maxTokens).toBe(32_000)
})
scenario("keeps the summary its room on a small window", function* (s) {
// 90% of 64k would leave 6.4k for the summary, so the 16k reserve sets the ceiling at 48k instead.
s.currentModel = smallWindowModel
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-small-first", 59_000))
yield* s.runPrompt("Earlier question")
s.requests.length = 0
yield* s.llm.push(
TestLLM.text("## Objective\n- Preserve the task", "text-small-summary"),
TestLLM.text("Continued", "text-small-final"),
)
yield* s.runPrompt("Recent request ".repeat(400))
expect(s.requests).toHaveLength(2)
expect(userTexts(s.requests[0]).at(-1)).toContain("## Objective")
expect(s.requests[0]?.generation?.maxTokens).toBe(16_000)
expect(s.requests[1]?.generation?.maxTokens).toBe(16_000)
})
scenario("publishes the original overflow when recovery summarization fails", function* (s) {
yield* setupOverflowRecovery(s)
yield* s.llm.push(
-1
View File
@@ -39,7 +39,6 @@ export function executeCallSummary(call: ExecuteCall) {
export function webSearchProviderName(provider: unknown) {
if (typeof provider !== "string" || !provider) return ""
if (provider === "opencode") return "OpenCode"
return `${provider[0].toUpperCase()}${provider.slice(1)}`
}
@@ -25,7 +25,6 @@ describe("webSearchProviderLabel", () => {
expect(webSearchProviderLabel("exa")).toBe("Web Search via Exa")
expect(webSearchProviderLabel("firecrawl")).toBe("Web Search via Firecrawl")
expect(webSearchProviderLabel("tavily")).toBe("Web Search via Tavily")
expect(webSearchProviderLabel("opencode")).toBe("Web Search via OpenCode")
})
test("labels providers dynamically", () => {
@@ -60,7 +60,6 @@ The current list of models includes:
- **Hy4 preview**
- **Hy3**
- **Space Bunny Free** (limited time)
- **LongCat 2.5 Preview Free** (limited time)
The list of models may change as we test and add new ones.
@@ -161,7 +160,6 @@ Token prices are per 1M tokens.
| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** |
| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** |
| Space Bunny Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
@@ -175,8 +173,6 @@ Token prices are per 1M tokens.
**Space Bunny Free:** Free for a limited time.
**LongCat 2.5 Preview Free:** Free for a limited time.
**DeepSeek V4.1 Flash / V4 Pro / V4 Flash Vision Exp:** Peak hours are 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday; all other hours, including weekends, are Off-Peak. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
**DeepSeek V4 Flash Vision Exp:** Images are converted into tokens based on their dimensions and billed as input tokens alongside text tokens. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
@@ -217,7 +213,6 @@ The table below provides an estimated request count based on typical Go usage pa
| Hy4 preview | 1,350 | 3,380 | 6,770 |
| Hy3 | 4,300 | 10,750 | 21,500 |
| Space Bunny Free | Unlimited | Unlimited | Unlimited |
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
| Grok 4.7 | 169 | 423 | 845 |
| Grok 4.6 | 169 | 423 | 845 |
| GPT 6 Luna | 4,230 | 10,560 | 21,130 |
@@ -320,7 +315,6 @@ You can also access Go models through the following API endpoints.
| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LongCat 2.5 Preview Free | longcat-2.5-preview-free | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
</div>
@@ -378,7 +372,6 @@ curl https://opencode.ai/zen/go/v1/models
| Hy4 preview | Not used | 0 days |
| Hy3 | Not used | 0 days |
| Space Bunny Free | Not used | 0 days |
| LongCat 2.5 Preview Free | Not used | 0 days |
- **Grok 4.7/4.6:** ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API. [Learn more](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr).
- **GPT 6 Luna / GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
@@ -108,7 +108,6 @@ You can also access the models directly through the following API endpoints.
| Jev 1.13 Free | jev-1.13-free | `https://opencode.ai/zen/v1/systemone` | - |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LongCat 2.5 Preview Free | longcat-2.5-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.6-Flash Free | mimo-v2.6-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Ling 3.0 Flash Fin Free | ling-3.0-flash-fin-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -198,7 +197,6 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| Space Bunny Free | Free | Free | Free | - |
| LongCat 2.5 Preview Free | Free | Free | Free | - |
| MiMo-V2.6-Flash Free | Free | Free | Free | - |
| MiMo-V2.5 Free | Free | Free | Free | - |
| Ling 3.0 Flash Fin Free | Free | Free | Free | - |
@@ -311,7 +309,6 @@ models to generate session titles.
- Nemotron 3.5 Lightning Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Space Bunny Free is a stealth model that's free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- LongCat 2.5 Preview Free is free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Muse Spark 1.3 Contributor Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Jev 1.13 Free is available on OpenCode for a limited time.