mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-26 18:47:35 +00:00
Compare commits
6
Commits
output-limits
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ed4f67df | ||
|
|
2109d68d39 | ||
|
|
cefb2968e2 | ||
|
|
00d179015f | ||
|
|
1d4e1233e5 | ||
|
|
d14f20b46e |
@@ -119,6 +119,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -135,6 +136,7 @@
|
||||
"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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-9gJjhes2ueYckAgdeGlPwZcaIDdwB3ZnqK/XHHXhWNs=",
|
||||
"aarch64-linux": "sha256-Sy5YXYM9tKevIITdV++bP35SJNaFCQVKwlNJRbWsD1Q=",
|
||||
"aarch64-darwin": "sha256-wiXHjKXm2VIFvalwITpSiRHaFZEWc8UJIqjQyc/0f0s=",
|
||||
"x86_64-darwin": "sha256-r/mnhdNbnPIJOY3qvtuY6GQ7ed1Nauq65X+8uERhdP8="
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"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:*",
|
||||
@@ -41,6 +42,7 @@
|
||||
"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",
|
||||
|
||||
@@ -142,10 +142,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "manage AI providers and credentials",
|
||||
description: "manage integrations and credentials",
|
||||
commands: [
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
description: "list integrations 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: "log in to a provider",
|
||||
description: "connect an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
@@ -228,7 +228,9 @@ 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")) },
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { 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 {
|
||||
@@ -21,10 +22,8 @@ const integrationPriority = new Map([
|
||||
["opencode", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
["anthropic", 4],
|
||||
["google", 5],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
@@ -74,30 +73,35 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
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
|
||||
.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),
|
||||
)
|
||||
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)
|
||||
})
|
||||
.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,
|
||||
}))
|
||||
}
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
@@ -143,17 +147,18 @@ const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
export 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, location },
|
||||
{ integrationID: integration.id, methodID: method.id, answer, label, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
@@ -190,16 +195,14 @@ const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
return
|
||||
}
|
||||
|
||||
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))),
|
||||
)
|
||||
// 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)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
log.success(`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"))
|
||||
})
|
||||
@@ -226,14 +229,21 @@ const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
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))))
|
||||
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)
|
||||
})
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
log.success(`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"))
|
||||
})
|
||||
|
||||
@@ -1,65 +1,109 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
OpenCode,
|
||||
type IntegrationAttemptStatus,
|
||||
type IntegrationOAuthMethod,
|
||||
type OpenCodeClient,
|
||||
} from "@opencode/client"
|
||||
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 { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
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"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
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}`))
|
||||
}),
|
||||
Effect.fn("cli.mcp.auth")((input) => authenticate(Option.getOrUndefined(input.name)).pipe(handlePromptErrors)),
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
}
|
||||
return status
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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`)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ 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,
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 },
|
||||
])
|
||||
})
|
||||
@@ -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 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("manage integrations and credentials")
|
||||
expect(auth.stdout).toContain("list integrations and credentials")
|
||||
expect(auth.stdout).toContain("connect an integration")
|
||||
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.toContain("connect")
|
||||
expect(auth.stdout).not.toMatch(/^ connect\s/m)
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(list.stdout).toContain("opencode auth list [flags]")
|
||||
expect(list.stdout).toContain("--format")
|
||||
@@ -216,7 +216,8 @@ describe("auth command", () => {
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` })
|
||||
})
|
||||
|
||||
test("settles the OAuth spinner when status polling fails", async () => {
|
||||
test("reports OAuth status polling failures and cancels the attempt", async () => {
|
||||
let cancelled = false
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
@@ -245,6 +246,7 @@ 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 })
|
||||
@@ -252,8 +254,10 @@ describe("auth command", () => {
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Authentication failed")
|
||||
expect(result.stdout).toContain("Waiting for authorization...")
|
||||
expect(result.stdout).toContain("UnexpectedStatus: 500")
|
||||
expect(result.stdout).toContain("Failed")
|
||||
expect(cancelled).toBe(true)
|
||||
expect(result.stdout).not.toContain("\n at ")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
})
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -308,13 +307,6 @@ 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")),
|
||||
|
||||
@@ -265,8 +265,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 overhead = Token.estimate(prompt) + Token.estimate(NUDGE)
|
||||
const prepared = yield* prepare(context, split.older, undefined, overhead)
|
||||
const prepared = yield* prepare(context, split.older)
|
||||
|
||||
// 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.
|
||||
@@ -289,6 +288,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const overhead = Token.estimate(prompt) + Token.estimate(NUDGE)
|
||||
return yield* deliver(trigger, prepared, split.recent, budget - overhead, send)
|
||||
})
|
||||
|
||||
@@ -551,15 +551,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
/** The conversation as the runner would send it, after request hooks. */
|
||||
/** `overhead` counts text sent outside the history, such as the summary prompt, toward the prompt size. */
|
||||
const prepare = (
|
||||
context: SessionContext.Loaded,
|
||||
messages: ReadonlyArray<SessionMessage.Info>,
|
||||
webSocket?: "session",
|
||||
overhead = 0,
|
||||
) => {
|
||||
const base = transcript(context, messages)
|
||||
const prompt = estimatePrompt({ ...context, messages })
|
||||
return requests.compaction({
|
||||
session: context.session,
|
||||
agent: context.agent.id,
|
||||
@@ -568,7 +565,6 @@ export const layer = Layer.effect(
|
||||
system: base.system,
|
||||
messages: base.messages,
|
||||
webSocket,
|
||||
inputTokens: { measured: prompt.measured, estimated: prompt.estimated + overhead },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -847,12 +843,6 @@ 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)))
|
||||
@@ -866,16 +856,11 @@ export const estimatePrompt = (context: SessionContext.Loaded) => {
|
||||
const unmeasured = sent.filter((message) => message.role !== "assistant" || message.id !== anchor?.id)
|
||||
|
||||
if (anchor?.type !== "assistant" || !anchor.tokens)
|
||||
return {
|
||||
measured: 0,
|
||||
estimated: estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured }),
|
||||
}
|
||||
return estimateRequest({ system: base.system, tools: context.tools.definitions, messages: unmeasured })
|
||||
|
||||
const tokens = anchor.tokens
|
||||
return {
|
||||
measured: tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning,
|
||||
estimated: unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0),
|
||||
}
|
||||
const measured = tokens.input + tokens.cache.read + tokens.cache.write + tokens.output + tokens.reasoning
|
||||
return measured + unmeasured.reduce((sum, message) => sum + estimateMessage(message), 0)
|
||||
}
|
||||
|
||||
/** The largest request the model takes while leaving room for its reply. */
|
||||
|
||||
@@ -44,14 +44,6 @@ 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))
|
||||
// Default output limit caps per request kind. Titles and generate have none and keep the provider default, because
|
||||
// their reasoning is hard to budget.
|
||||
const OUTPUT_TOKEN_CAPS: Partial<Record<SessionRequestKind, number>> = { primary: 256_000, compaction: 32_000 }
|
||||
// Used when the catalog has no output limit for the model.
|
||||
const OUTPUT_TOKEN_FALLBACK = 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.15
|
||||
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
|
||||
@@ -77,16 +69,6 @@ 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, capped, and fitted to the room the prompt leaves in the context window. */
|
||||
export const outputLimit = (limit: Model.Info["limit"], cap: number, inputTokens?: Input["inputTokens"]) => {
|
||||
const requested = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, cap)
|
||||
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: {
|
||||
@@ -236,16 +218,8 @@ 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.
|
||||
const cap = OUTPUT_TOKEN_CAPS[kind]
|
||||
const shaped = yield* shape(
|
||||
{
|
||||
sessionID: session.id,
|
||||
model: model.ref,
|
||||
system: input.system,
|
||||
messages: input.messages,
|
||||
options: cap === undefined ? {} : { maxTokens: outputLimit(model.limit, cap, input.inputTokens) },
|
||||
},
|
||||
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
|
||||
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
|
||||
)
|
||||
// Match by identity first, then by key. Entries matching neither were invented by a
|
||||
|
||||
@@ -240,7 +240,6 @@ 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,31 +216,6 @@ 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 { GenerationOptions, LLMClient, LLMEvent, LanguageModel, ToolDefinition, type LLMRequest } from "@opencode/ai"
|
||||
import { 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"
|
||||
@@ -452,7 +452,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 32_000 }))
|
||||
expect(requests[0]?.generation).toBeUndefined()
|
||||
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,61 +154,3 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionModelRequest output limit", () => {
|
||||
const input = { session, agent: Agent.ID.make("build"), model, system: [], messages: [] }
|
||||
|
||||
it.effect("caps the default output limit per request kind", () =>
|
||||
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(256_000)
|
||||
expect(maxTokens(yield* requests.compaction(large))).toBe(32_000)
|
||||
const inputTokens = { measured: 170_000, estimated: 8_000 }
|
||||
expect(maxTokens(yield* requests.primary({ ...input, inputTokens }))).toBe(20_800)
|
||||
expect(maxTokens(yield* requests.compaction({ ...input, inputTokens }))).toBe(20_800)
|
||||
}).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,40 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart, Media } from "@opencode/ai"
|
||||
import { boundImages, outputLimit, unsupportedParts } from "@opencode/core/session/model-request"
|
||||
import { boundImages, 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 up to the cap", () => {
|
||||
expect(outputLimit({ context: 1_000_000, output: 128_000 }, 256_000)).toBe(128_000)
|
||||
expect(outputLimit({ context: 200_000, output: 64_000 }, 256_000)).toBe(64_000)
|
||||
expect(outputLimit({ context: 1_048_576, output: 1_048_576 }, 256_000)).toBe(256_000)
|
||||
expect(outputLimit({ context: 200_000, output: 64_000 }, 32_000)).toBe(32_000)
|
||||
})
|
||||
|
||||
test("falls back to 32k when the catalog has no output limit", () => {
|
||||
expect(outputLimit({ context: 200_000, output: 0 }, 256_000)).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, 256_000, { measured: 50_000, estimated: 0 })).toBe(128_000)
|
||||
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 0 })).toBe(100_000)
|
||||
// Estimated text counts 15% extra, so 40k estimated takes 46k of the room.
|
||||
expect(outputLimit(limit, 256_000, { measured: 900_000, estimated: 40_000 })).toBe(54_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 }, 256_000, prompt)).toBe(1_024)
|
||||
expect(outputLimit({ context: 200_000, output: 512 }, 256_000, prompt)).toBe(512)
|
||||
})
|
||||
|
||||
test("ignores the prompt size when the context window is unknown", () => {
|
||||
expect(outputLimit({ context: 0, output: 32_000 }, 256_000, { measured: 500_000, estimated: 0 })).toBe(32_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.unsupportedParts", () => {
|
||||
test("replaces unsupported user media with a visible error", () => {
|
||||
const messages = unsupportedParts(
|
||||
|
||||
@@ -126,7 +126,6 @@ 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 })
|
||||
|
||||
test("calculates step cost using the matching context tier", () => {
|
||||
expect(
|
||||
@@ -3206,7 +3205,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* Effect.exit(s.resume)).toMatchObject({ _tag: "Failure" })
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(s.requests[0]?.generation?.maxTokens).toBe(50)
|
||||
expect(s.requests[0]?.generation).toBeUndefined()
|
||||
expect(yield* s.context).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
@@ -3411,18 +3410,6 @@ 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 - 100)
|
||||
})
|
||||
|
||||
scenario("publishes the original overflow when recovery summarization fails", function* (s) {
|
||||
yield* setupOverflowRecovery(s)
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -39,6 +39,7 @@ 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,6 +25,7 @@ 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,6 +60,7 @@ 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.
|
||||
|
||||
@@ -160,6 +161,7 @@ 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** |
|
||||
@@ -173,6 +175,8 @@ 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/).
|
||||
@@ -213,6 +217,7 @@ 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 |
|
||||
@@ -315,6 +320,7 @@ 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>
|
||||
|
||||
@@ -372,6 +378,7 @@ 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,6 +108,7 @@ 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` |
|
||||
@@ -197,6 +198,7 @@ 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 | - |
|
||||
@@ -309,6 +311,7 @@ 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user