mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 01:16:24 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c088fc0d | ||
|
|
1de8d14ef2 | ||
|
|
46ce3cb2c9 | ||
|
|
e82a4a1da4 | ||
|
|
ee2e318ec7 | ||
|
|
33f48f36c9 | ||
|
|
58f949d2d0 | ||
|
|
2a895b9e03 | ||
|
|
fe506f201d |
@@ -605,7 +605,10 @@ export type SessionLogOutput =
|
||||
readonly type: "session.execution.interrupted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
|
||||
type: "session.execution.interrupted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
|
||||
}
|
||||
|
||||
export type SessionInstructionsUpdated = {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
export * as Credential from "./credential.js"
|
||||
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
import { ErrorSummary } from "./util/error-summary.js"
|
||||
|
||||
export const ID = Credential.ID
|
||||
export type ID = Credential.ID
|
||||
@@ -123,7 +124,21 @@ const layer = Layer.effect(
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential create failed", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* Effect.logInfo("credential created", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
type: credential.value.type,
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
@@ -154,8 +169,19 @@ const layer = Layer.effect(
|
||||
return credential.integration_id
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential activate failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (integrationID) {
|
||||
yield* Effect.logInfo("credential activated", { integrationID, credentialID: id })
|
||||
yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}
|
||||
}),
|
||||
update: Effect.fn("Credential.update")(function* (id, updates) {
|
||||
if (updates.label === undefined && updates.value === undefined) return
|
||||
@@ -164,15 +190,46 @@ const layer = Layer.effect(
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!credential?.integrationID) return
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update lookup failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!credential?.integrationID) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_missing" })
|
||||
return
|
||||
}
|
||||
if (updates.label === credential.label && updates.value === undefined) return
|
||||
yield* db
|
||||
const updated = yield* db
|
||||
.update(CredentialTable)
|
||||
.set({ label: updates.label, value: updates.value })
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
.returning({ id: CredentialTable.id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update failed", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!updated) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_removed" })
|
||||
return
|
||||
}
|
||||
yield* Effect.logInfo("credential updated", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
valueChanged: updates.value !== undefined,
|
||||
labelChanged: updates.label !== undefined && updates.label !== credential.label,
|
||||
})
|
||||
if (updates.label !== undefined && updates.label !== credential.label)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
}),
|
||||
@@ -191,7 +248,8 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
: undefined
|
||||
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
|
||||
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
|
||||
if (!credential.integration_id || active?.id !== id)
|
||||
return { switched: false as const, integrationID: credential.integration_id }
|
||||
const replacement = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
@@ -217,8 +275,22 @@ const layer = Layer.effect(
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential remove failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!removed) return
|
||||
yield* Effect.logInfo("credential removed", {
|
||||
credentialID: id,
|
||||
integrationID: removed.integrationID,
|
||||
active: removed.switched,
|
||||
...(removed.switched ? { replacementID: removed.credentialID } : {}),
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
if (removed.switched)
|
||||
yield* bus.publish(
|
||||
|
||||
@@ -24,7 +24,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
const sessions = yield* SessionStore.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
@@ -51,20 +51,36 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
if (expired.length === 0) return
|
||||
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
const occupied = new Set(active.flatMap((session) => (session ? [key(session.location)] : [])))
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) => {
|
||||
// Waiting for a question or a long-running tool emits no activity.
|
||||
// Invalidating a borrowed graph would strand it behind a new cache entry.
|
||||
if (occupied.has(key(entry.ref))) return touch(entry.ref)
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const owners = active.flatMap((session) =>
|
||||
session && key(session.location) === key(entry.ref) ? [session] : [],
|
||||
)
|
||||
// Invalidation only detaches the cache entry; borrowers retain the old
|
||||
// graph. Stop its executions and settle tool cleanup before detaching it.
|
||||
yield* Effect.forEach(
|
||||
owners,
|
||||
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
|
||||
{
|
||||
discard: true,
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
// New work admitted during cleanup may now own the cached graph.
|
||||
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
|
||||
yield* touch(entry.ref)
|
||||
return
|
||||
}
|
||||
entries.delete(key(entry.ref))
|
||||
yield* Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
}),
|
||||
{ discard: true, concurrency: "unbounded" },
|
||||
)
|
||||
}).pipe(Effect.forever, Effect.forkScoped)
|
||||
|
||||
|
||||
@@ -231,6 +231,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
@@ -240,6 +242,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -259,27 +259,41 @@ export const layer = (options?: Options) =>
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const base = {
|
||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
||||
scope: oauth?.scope,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
|
||||
}
|
||||
const found = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!found || found.value.type !== "oauth")
|
||||
if (!found || found.value.type !== "oauth") {
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
yield* Effect.logInfo("mcp oauth credential unavailable", {
|
||||
integrationID: entry.integrationID,
|
||||
reason: found ? "not_oauth" : "missing",
|
||||
})
|
||||
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
|
||||
}
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
const fields = { credentialID, integrationID: entry.integrationID }
|
||||
yield* Effect.logInfo("mcp oauth credential loaded", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(found.value.refresh),
|
||||
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
|
||||
expiresAt: found.value.expires,
|
||||
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
|
||||
})
|
||||
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
|
||||
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
|
||||
let presented = found.value.refresh
|
||||
const readOAuthCredential = async () => {
|
||||
const stored = await Effect.runPromise(credentials.get(credentialID))
|
||||
const stored = await run(credentials.get(credentialID))
|
||||
return stored?.value.type === "oauth" ? stored.value : undefined
|
||||
}
|
||||
return McpOAuth.provider({
|
||||
@@ -290,10 +304,25 @@ export const layer = (options?: Options) =>
|
||||
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
|
||||
// reconnects remain serialized by the server lock.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
if (scope === "verifier" || scope === "discovery") {
|
||||
await run(
|
||||
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
if (!oauth || oauth.refresh !== presented) {
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth invalidation skipped", {
|
||||
...fields,
|
||||
scope,
|
||||
reason: oauth ? "token_rotated" : "credential_missing",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
|
||||
await run(credentials.remove(credentialID))
|
||||
},
|
||||
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
|
||||
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
|
||||
@@ -314,7 +343,16 @@ export const layer = (options?: Options) =>
|
||||
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth tokens received", {
|
||||
...fields,
|
||||
credentialPresent: Boolean(previous),
|
||||
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
|
||||
hasRefreshToken: Boolean(value.refresh),
|
||||
expiresAt: value.expires,
|
||||
}),
|
||||
)
|
||||
await run(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
@@ -560,7 +598,10 @@ export const layer = (options?: Options) =>
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
}).pipe(
|
||||
Effect.ensuring(entry.startup.open),
|
||||
Effect.annotateLogs({ server: name, directory: location.directory, connectionID: crypto.randomUUID() }),
|
||||
)
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
const scope = entry.scope
|
||||
|
||||
@@ -1,12 +1,63 @@
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page.js"
|
||||
import type { Integration } from "../integration.js"
|
||||
import { ErrorSummary } from "../util/error-summary.js"
|
||||
|
||||
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
|
||||
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const request: FetchLike = (url, init) => {
|
||||
const grant = init?.body instanceof URLSearchParams ? init.body.get("grant_type") : undefined
|
||||
const operation = grant === "refresh_token" ? "refresh" : grant === "authorization_code" ? "exchange" : undefined
|
||||
const started = Date.now()
|
||||
return run(
|
||||
Effect.gen(function* () {
|
||||
if (operation) yield* Effect.logInfo("mcp oauth request started")
|
||||
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
|
||||
const result = { status: response.status, durationMs: Date.now() - started }
|
||||
if (operation && !response.ok) {
|
||||
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
|
||||
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
|
||||
Effect.map((error) => error.errorCode),
|
||||
Effect.orElseSucceed(() => "unreadable_response"),
|
||||
)
|
||||
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
|
||||
}
|
||||
if (operation && response.ok) {
|
||||
yield* Effect.logInfo("mcp oauth request succeeded", result)
|
||||
}
|
||||
if (!operation && (response.status === 401 || response.status === 403)) {
|
||||
yield* Effect.logWarning("mcp http authentication rejected", result)
|
||||
}
|
||||
return response
|
||||
}).pipe(
|
||||
Effect.onError((cause) => {
|
||||
if (init?.signal?.aborted) return Effect.logDebug("mcp http request aborted")
|
||||
return Effect.logWarning("mcp http request failed", {
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
Effect.annotateLogs({
|
||||
...fields,
|
||||
requestID: crypto.randomUUID(),
|
||||
origin: new URL(url).origin,
|
||||
method: init?.method ?? "GET",
|
||||
...(operation ? { operation } : {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
||||
export interface Store {
|
||||
@@ -145,6 +196,12 @@ export const authorize = (input: {
|
||||
readonly methodID: Integration.MethodID
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const fields = { server: input.name, methodID: input.methodID, oauthAttemptID: crypto.randomUUID() }
|
||||
const context = yield* Effect.context()
|
||||
const run = Effect.runPromiseWith(context)
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
|
||||
yield* Effect.logInfo("mcp oauth authorization started", fields)
|
||||
const oauth = input.config.oauth || undefined
|
||||
const store = memoryStore()
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
@@ -160,19 +217,20 @@ export const authorize = (input: {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
const fail = (reason: string, failure: string) => {
|
||||
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
if (error) return fail(error)
|
||||
if (error) return fail(error, "authorization_error")
|
||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code")
|
||||
if (!value) return fail("Missing authorization code", "missing_code")
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
@@ -202,6 +260,7 @@ export const authorize = (input: {
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
|
||||
},
|
||||
store,
|
||||
})
|
||||
@@ -210,11 +269,16 @@ export const authorize = (input: {
|
||||
const tokens = yield* Effect.promise(() => store.tokens())
|
||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
||||
const client = yield* Effect.promise(() => store.clientInformation())
|
||||
yield* Effect.logInfo("mcp oauth authorization completed", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(tokens.refresh_token),
|
||||
expiresIn: tokens.expires_in,
|
||||
})
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
@@ -229,11 +293,28 @@ export const authorize = (input: {
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value,
|
||||
scope: oauth?.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
),
|
||||
}
|
||||
})
|
||||
}).pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization setup failed", {
|
||||
server: input.name,
|
||||
methodID: input.methodID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ export type Editor = {
|
||||
|
||||
export type AutoInput = {
|
||||
readonly context: SessionContext.Loaded
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
readonly prepare: SessionModelRequest.Interface["compaction"]
|
||||
}
|
||||
|
||||
type RequiredInput = {
|
||||
@@ -113,7 +113,7 @@ export type ManualInput = {
|
||||
SessionContext.Loaded & { readonly instructionUpdate: string },
|
||||
SessionRunnerModel.Error | AgentNotFoundError | Instructions.InitializationBlocked
|
||||
>
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
readonly prepare: SessionModelRequest.Interface["compaction"]
|
||||
}
|
||||
|
||||
type ExecuteInput = AutoInput & {
|
||||
@@ -396,26 +396,20 @@ export const layer = Layer.effect(
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* input.prepare({
|
||||
kind: "compaction",
|
||||
scope: {
|
||||
session: context.session,
|
||||
agentID: Agent.ID.make("compaction"),
|
||||
contextAgentID: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
session: context.session,
|
||||
agent: context.agent.id,
|
||||
model: context.model,
|
||||
tools: context.tools,
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
Message.user(
|
||||
buildPrompt(
|
||||
history.messages.some((message) => message.type === "compaction" && message.status === "completed"),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
})
|
||||
const retry = yield* SessionRunnerRetry.policy(context.session.id)
|
||||
// Both requests share the retry allowance; rejected output never enters the reminder request.
|
||||
|
||||
@@ -64,7 +64,8 @@ export interface Interface {
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
readonly prepare: SessionModelRequest.Interface["prepare"]
|
||||
/** Outbound request preparation, one entry per Session flow. */
|
||||
readonly request: SessionModelRequest.Interface
|
||||
}
|
||||
|
||||
/** Location-scoped model-context loader for durable Session Steps. */
|
||||
@@ -83,7 +84,7 @@ const layer = Layer.effect(
|
||||
const mcpInstructions = yield* McpInstructions.Service
|
||||
const mcpTools = yield* McpTool.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const request = yield* SessionModelRequest.Service
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
const skillInstructions = yield* SkillInstructions.Service
|
||||
const store = yield* SessionStore.Service
|
||||
@@ -167,7 +168,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
|
||||
return Service.of({ select, load, resolveModel, selectTitle, request })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -28,9 +28,17 @@ export interface Interface {
|
||||
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
|
||||
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
|
||||
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
|
||||
* settlement matters.
|
||||
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
|
||||
* rather than fresh work admitted during its cleanup.
|
||||
*/
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
readonly interrupt: (
|
||||
sessionID: SessionSchema.ID,
|
||||
options?: {
|
||||
readonly continue?: boolean
|
||||
readonly reason?: "user" | "inactivity"
|
||||
readonly awaitSettlement?: boolean
|
||||
},
|
||||
) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -38,7 +46,7 @@ export interface Interface {
|
||||
/** Routes execution from a Session ID to its selected instance's runner. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
type InterruptReason = "user" | "shutdown" | "inactivity"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
@@ -120,9 +128,8 @@ export const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
|
||||
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
|
||||
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -147,7 +154,7 @@ export const layer = Layer.effect(
|
||||
isActive: coordinator.isActive,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, "user")
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
|
||||
if (!options?.continue) return interrupted
|
||||
// Resume steering input and between-turn control work from the interrupted
|
||||
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
|
||||
|
||||
@@ -37,17 +37,17 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "generate",
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
},
|
||||
const prepared = yield* context.request.generate({
|
||||
session: selection.session,
|
||||
agent: selection.agent.id,
|
||||
model,
|
||||
tools: selection.tools,
|
||||
system: transcript.system,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
Message.user(input.prompt),
|
||||
],
|
||||
})
|
||||
yield* Effect.logInfo("sending session generation request", {
|
||||
sessionID: selection.session.id,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
export * as SessionModelRequest from "./model-request.js"
|
||||
|
||||
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import {
|
||||
GenerationOptions,
|
||||
type GenerationOptionsFields,
|
||||
HttpOptions,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMRequest,
|
||||
Message,
|
||||
SystemPart,
|
||||
} from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { SessionRequest, SessionRequestKind } from "@opencode-ai/plugin/effect/session"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
@@ -25,63 +34,30 @@ const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
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))
|
||||
|
||||
const responsesWebSocketFlag = (providerID: string) =>
|
||||
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
|
||||
|
||||
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
||||
/** Tool errors, plus the user declining a permission or dismissing a question. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
|
||||
// User declines dive under the leaves' blanket `mapError` as defects (the deliberate
|
||||
// tunnel entered in Permission.assert and the question tool), so a user's "no" can
|
||||
// never become model-facing tool output. They resurface as typed failures exactly once,
|
||||
// here at the seam the runner executes through.
|
||||
const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
const decline = cause.reasons.flatMap((reason) =>
|
||||
Cause.isDieReason(reason) &&
|
||||
(reason.defect instanceof Permission.DeclinedError || reason.defect instanceof QuestionTool.CancelledError)
|
||||
? [reason.defect]
|
||||
: [],
|
||||
)[0]
|
||||
return decline ? Result.succeed(decline) : Result.fail(cause)
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void>
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
*/
|
||||
/** Runs a tool call against the tools this request advertised. */
|
||||
readonly executeTool: (
|
||||
input: Parameters<Tool.Snapshot["execute"]>[0],
|
||||
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
/** Which Session flow issues this request; request hooks receive it alongside the Session identity. */
|
||||
readonly kind: SessionRequestKind
|
||||
readonly scope: {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
/** Agent whose context an auxiliary request reuses, without changing its request-hook identity. */
|
||||
readonly contextAgentID?: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for requests that carry no tool definitions, such as titles. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
export interface Input {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agent: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools?: Tool.Snapshot
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Standalone requests
|
||||
* such as titles opt out; compaction uses the selected Session context.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
/** Only the durable runner may use a stateful WebSocket. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
|
||||
@@ -195,90 +171,18 @@ export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
)
|
||||
}
|
||||
|
||||
/** The identity a plugin hook sees for one outbound request. */
|
||||
interface HookScope {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly kind: SessionRequestKind
|
||||
}
|
||||
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
|
||||
|
||||
const sessionHeaders = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
|
||||
"x-session-affinity": session.id,
|
||||
"X-Session-Id": session.id,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
"User-Agent": App.useragent(app),
|
||||
"x-opencode-project": session.projectID,
|
||||
"x-opencode-session": session.id,
|
||||
"x-opencode-client": app.name,
|
||||
})
|
||||
|
||||
const promptCacheKey = (sessionID: SessionSchema.ID) =>
|
||||
/^ses_[0-9a-f]{64}$/.test(sessionID) ? sessionID.slice(4) : sessionID
|
||||
|
||||
// Lets session.model.request hooks rewrite the base URL and headers before dispatch.
|
||||
const applyModelHooks = (hooks: PluginHooks.Interface, scope: HookScope, request: LLMRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const currentBaseURL = request.model.route.endpoint.baseURL
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
...scope,
|
||||
baseURL: typeof currentBaseURL === "string" ? currentBaseURL : undefined,
|
||||
headers: { ...request.http?.headers },
|
||||
})
|
||||
const route =
|
||||
event.baseURL !== undefined && event.baseURL !== currentBaseURL
|
||||
? request.model.route.with({ endpoint: { baseURL: event.baseURL } })
|
||||
: request.model.route
|
||||
return LLMRequest.update(request, {
|
||||
model: route === request.model.route ? request.model : LanguageModel.update(request.model, { route }),
|
||||
http: new HttpOptions({
|
||||
body: request.http?.body,
|
||||
headers: Object.keys(event.headers).length === 0 ? undefined : event.headers,
|
||||
query: request.http?.query,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Exposes each outbound HTTP exchange to session.http.request/response hooks
|
||||
// through web-standard Request/Response values.
|
||||
const httpMiddleware =
|
||||
(hooks: PluginHooks.Interface, scope: HookScope): NonNullable<StreamOptions["http"]> =>
|
||||
(request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
...scope,
|
||||
request: yield* HttpClientRequest.toWeb(request),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const response = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
...scope,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||
{ status: response.status, headers: response.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
|
||||
/**
|
||||
* Builds an outbound model request and captures the tool-call capability that
|
||||
* must remain paired with it. It does not execute the request or mutate
|
||||
* Session state.
|
||||
*/
|
||||
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */
|
||||
export interface Interface {
|
||||
/** Builds one outbound model request and its matching tool-call capability. */
|
||||
readonly prepare: (input: PrepareInput) => Effect.Effect<Prepared>
|
||||
readonly primary: (input: Input) => Effect.Effect<Prepared>
|
||||
/** The context hook sees the session agent; request hooks see the `compaction` agent. */
|
||||
readonly compaction: (input: Input) => Effect.Effect<Prepared>
|
||||
readonly generate: (input: Input) => Effect.Effect<Prepared>
|
||||
/** Runs `session.title` instead of `session.context`; no agent or tools. */
|
||||
readonly title: (input: Input) => Effect.Effect<Prepared>
|
||||
}
|
||||
|
||||
/** Location-scoped outbound model-request preparation. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
@@ -287,102 +191,157 @@ export const layer = Layer.effect(
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const app = yield* App.Metadata
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
const tools = input.scope.tools ?? {
|
||||
|
||||
// `shape` runs the flow's plugin hook. Hooks mutate `tools` in place, so it is passed separately.
|
||||
const prepare = Effect.fn("SessionModelRequest.prepare")(function* (
|
||||
kind: SessionRequestKind,
|
||||
input: Input,
|
||||
shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<SessionRequest & { tools?: Definitions }>,
|
||||
) {
|
||||
const session = input.session
|
||||
const model = input.model
|
||||
const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind }
|
||||
const tools = input.tools ?? {
|
||||
definitions: [],
|
||||
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
|
||||
}
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
// Remember which tool each definition object came from. Hooks rename a tool by moving
|
||||
// its definition to a new key, so after the hook we find the tool by object identity.
|
||||
const given = new Map(
|
||||
tools.definitions.map(
|
||||
(tool) => [{ description: tool.description, input: { ...tool.inputSchema } }, tool] as const,
|
||||
),
|
||||
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context: PluginHooks.Domains["session"]["context"] = {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.contextAgentID ?? input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
}
|
||||
if (input.contextHooks !== false) yield* hooks.trigger("session", "context", context)
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
// `tool.name` stays canonical so execution can translate renamed calls back.
|
||||
const shaped = yield* shape(
|
||||
{ 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
|
||||
// hook and are dropped. `t.name` stays the real name so execution can map renames back.
|
||||
const byName = new Map(tools.definitions.map((t) => [t.name, t]))
|
||||
const hooked = new Map(
|
||||
Object.entries(context.tools).flatMap(([name, definition]) => {
|
||||
const tool = given.get(definition) ?? registry.get(name)
|
||||
if (!tool) return []
|
||||
return [[name, { ...tool, description: definition.description, inputSchema: definition.input }] as const]
|
||||
Object.entries(shaped.tools ?? {}).flatMap(([name, d]) => {
|
||||
const t = given.get(d) ?? byName.get(name)
|
||||
return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : []
|
||||
}),
|
||||
)
|
||||
const request = yield* applyModelHooks(
|
||||
hooks,
|
||||
{ sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind },
|
||||
LLM.request({
|
||||
model,
|
||||
http: {
|
||||
headers: sessionHeaders(session, app),
|
||||
const entries = Object.entries(shaped.options)
|
||||
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
|
||||
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
|
||||
const root = session.fork?.sessionID ?? session.id
|
||||
const base = LLM.request({
|
||||
model: model.model,
|
||||
http: {
|
||||
headers: {
|
||||
"x-session-affinity": session.id,
|
||||
"X-Session-Id": session.id,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
"User-Agent": App.useragent(app),
|
||||
"x-opencode-project": session.projectID,
|
||||
"x-opencode-session": session.id,
|
||||
"x-opencode-client": app.name,
|
||||
},
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: promptCacheKey(session.fork?.sessionID ?? session.id),
|
||||
system: context.system,
|
||||
messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)),
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: Object.keys(context.generation).length === 0 ? undefined : context.generation,
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
},
|
||||
// TODO: Persist cache lineage so nested forks reuse the root session's cache key.
|
||||
promptCacheKey: /^ses_[0-9a-f]{64}$/.test(root) ? root.slice(4) : root,
|
||||
system: shaped.system,
|
||||
messages: boundImages(unsupportedParts(shaped.messages, model.capabilities)),
|
||||
tools: Array.from(hooked, ([name, t]) => ({ ...t, name })),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: Object.keys(generation).length === 0 ? undefined : generation,
|
||||
providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions,
|
||||
})
|
||||
|
||||
const baseURL = base.model.route.endpoint.baseURL
|
||||
const modelHook = yield* hooks.trigger("session", "model.request", {
|
||||
...scope,
|
||||
baseURL: typeof baseURL === "string" ? baseURL : undefined,
|
||||
headers: { ...base.http?.headers },
|
||||
})
|
||||
const route =
|
||||
modelHook.baseURL !== undefined && modelHook.baseURL !== baseURL
|
||||
? base.model.route.with({ endpoint: { baseURL: modelHook.baseURL } })
|
||||
: base.model.route
|
||||
const request = LLMRequest.update(base, {
|
||||
model: route === base.model.route ? base.model : LanguageModel.update(base.model, { route }),
|
||||
http: new HttpOptions({
|
||||
body: base.http?.body,
|
||||
headers: Object.keys(modelHook.headers).length === 0 ? undefined : modelHook.headers,
|
||||
query: base.http?.query,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Hooks see each HTTP exchange as web Request/Response values. WebSockets bypass this,
|
||||
// so registering an HTTP hook forces HTTP.
|
||||
const hasHttpHooks =
|
||||
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
const webSocket =
|
||||
resolved.capabilities.responsesWebsockets === true
|
||||
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
: false
|
||||
const http = hasHttpHooks
|
||||
? httpMiddleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
kind: input.kind,
|
||||
})
|
||||
(yield* hooks.has("session", "http.request", model.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", model.ref.providerID))
|
||||
const http: StreamOptions["http"] = hasHttpHooks
|
||||
? (req, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const before = yield* hooks.trigger("session", "http.request", {
|
||||
...scope,
|
||||
request: yield* HttpClientRequest.toWeb(req),
|
||||
})
|
||||
let sent = HttpClientRequest.fromWeb(before.request)
|
||||
if (before.request.body)
|
||||
sent = HttpClientRequest.bodyUint8Array(
|
||||
sent,
|
||||
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||
before.request.headers.get("content-type") ?? undefined,
|
||||
)
|
||||
const res = yield* handler(sent)
|
||||
const after = yield* hooks.trigger("session", "http.response", {
|
||||
...scope,
|
||||
request: before.request,
|
||||
response: new Response(
|
||||
[204, 205, 304].includes(res.status) ? null : yield* Stream.toReadableStreamEffect(res.stream),
|
||||
{ status: res.status, headers: res.headers },
|
||||
),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(sent, after.response)
|
||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||
: undefined
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(input.webSocket === "session" && webSocket && !hasHttpHooks
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
const executeTool: Prepared["executeTool"] = (input) =>
|
||||
tools
|
||||
.execute({ ...input, definitions: hooked })
|
||||
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
|
||||
const retry: Prepared["retry"] = (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid)
|
||||
const webSocket =
|
||||
input.webSocket === "session" &&
|
||||
!hasHttpHooks &&
|
||||
model.capabilities.responsesWebsockets === true &&
|
||||
(yield* Config.boolean(
|
||||
`OPENCODE_EXPERIMENTAL_${model.ref.providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`,
|
||||
).pipe(Config.withDefault(false), Effect.orDie))
|
||||
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
retry,
|
||||
executeTool,
|
||||
}
|
||||
options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket: transport.bind(session.id) } : {}) },
|
||||
retry: (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
|
||||
// Permission.assert and the question tool throw declines as defects so tools cannot
|
||||
// catch them and turn a "no" into model-visible output. Recover them here as failures.
|
||||
executeTool: (call) =>
|
||||
tools.execute({ ...call, definitions: hooked }).pipe(
|
||||
Effect.catchCauseFilter(
|
||||
(cause) => {
|
||||
const decline = cause.reasons.flatMap((r) =>
|
||||
Cause.isDieReason(r) &&
|
||||
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
|
||||
? [r.defect]
|
||||
: [],
|
||||
)[0]
|
||||
return decline ? Result.succeed(decline) : Result.fail(cause)
|
||||
},
|
||||
(decline) => Effect.fail(decline),
|
||||
),
|
||||
),
|
||||
} satisfies Prepared
|
||||
})
|
||||
|
||||
return Service.of({ prepare })
|
||||
const context = (agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) =>
|
||||
hooks.trigger("session", "context", { ...draft, agent, tools })
|
||||
|
||||
return Service.of({
|
||||
primary: (input) => prepare("primary", input, context(input.agent)),
|
||||
generate: (input) => prepare("generate", input, context(input.agent)),
|
||||
compaction: (input) =>
|
||||
prepare("compaction", { ...input, agent: Agent.ID.make("compaction") }, context(input.agent)),
|
||||
title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
|
||||
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
|
||||
* finalizers and settled hook on its own time. Returns whether an active execution was
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
|
||||
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
|
||||
*/
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly awaitSettlement?: boolean },
|
||||
) => Effect.Effect<boolean>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -170,5 +175,22 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
|
||||
return {
|
||||
active: Effect.sync(() => new Set(executions.keys())),
|
||||
isActive,
|
||||
run,
|
||||
wake,
|
||||
interrupt: (key, reason, options) =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return interrupt(key, reason).pipe(
|
||||
Effect.tap(() =>
|
||||
options?.awaitSettlement && execution
|
||||
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}),
|
||||
awaitIdle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -124,7 +124,7 @@ const layer = Layer.effect(
|
||||
instructionUpdate: history.instructionUpdate,
|
||||
}
|
||||
}),
|
||||
prepare: context.prepare,
|
||||
prepare: context.request.compaction,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
@@ -203,7 +203,7 @@ const layer = Layer.effect(
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
context: loaded,
|
||||
prepare: context.prepare,
|
||||
prepare: context.request.compaction,
|
||||
}
|
||||
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
@@ -219,15 +219,15 @@ const layer = Layer.effect(
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "primary",
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
const prepared = yield* context.request.primary({
|
||||
session: loaded.session,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
|
||||
@@ -63,14 +63,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* context.prepare({
|
||||
kind: "title",
|
||||
scope: { session: input.session, agentID: input.agent.id, model: input.model },
|
||||
transcript: {
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
},
|
||||
contextHooks: false,
|
||||
const prepared = yield* context.request.title({
|
||||
session: input.session,
|
||||
agent: input.agent.id,
|
||||
model: input.model,
|
||||
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
|
||||
messages: [Message.user(input.text)],
|
||||
})
|
||||
yield* llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export * as ErrorSummary from "./error-summary.js"
|
||||
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const decode = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
_tag: Schema.optional(Schema.String),
|
||||
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
errno: Schema.optional(Schema.Number),
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
|
||||
export function from(error: unknown) {
|
||||
const errors: { type: string; code?: string | number; errno?: number }[] = []
|
||||
const seen = new Set<unknown>()
|
||||
while (error && !seen.has(error) && errors.length < 8) {
|
||||
seen.add(error)
|
||||
const result = decode(error)
|
||||
if (Option.isNone(result)) break
|
||||
errors.push({
|
||||
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
|
||||
code: result.value.code,
|
||||
errno: result.value.errno,
|
||||
})
|
||||
error = error instanceof Error ? error.cause : result.value.cause
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -95,7 +95,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: () => Effect.succeed({ ...nearInput.context, messages, instructionUpdate: "" }),
|
||||
prepare: modelRequests.prepare,
|
||||
prepare: modelRequests.compaction,
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_compaction_manual"),
|
||||
}),
|
||||
|
||||
@@ -15,8 +15,10 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -26,7 +28,7 @@ const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
return yield* LayerMap.make(
|
||||
const map = yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
// The fixture only exercises these three Location services.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
@@ -51,7 +53,11 @@ const locations = Layer.effect(
|
||||
title: "Questions",
|
||||
fields: [{ key: "runtime", type: "string" }],
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.as(SessionRunner.DrainResult.Complete())),
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.as(SessionRunner.DrainResult.Complete()),
|
||||
Effect.onInterrupt(() => Effect.sleep("5 minutes")),
|
||||
),
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -62,12 +68,26 @@ const locations = Layer.effect(
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
return {
|
||||
...map,
|
||||
get: (ref: Location.Ref) => map.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => map.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => map.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => map.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, SessionExecution.node, LocationActivity.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionExecution.node,
|
||||
LocationActivity.node,
|
||||
]),
|
||||
[
|
||||
LocationServiceMap.node.replace(
|
||||
makeGlobalNode({
|
||||
@@ -80,70 +100,122 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationActivity active execution", () => {
|
||||
for (const settle of ["answer", "cancel", "interrupt"] as const) {
|
||||
it.effect(`keeps a waiting question reachable past the deadline until ${settle}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const sessionID = Session.ID.make("ses_waiting_question")
|
||||
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
|
||||
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "question",
|
||||
directory: ref.directory,
|
||||
title: "Waiting question",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
describe("LocationActivity eviction", () => {
|
||||
for (const [count, admission] of [
|
||||
[1, "none"],
|
||||
[2, "none"],
|
||||
[1, "other"],
|
||||
[1, "same"],
|
||||
] as const) {
|
||||
const newWork = admission !== "none"
|
||||
it.effect(
|
||||
`interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionIDs = Array.from({ length: count }, (_, index) =>
|
||||
Session.ID.make(`ses_waiting_question_${index}`),
|
||||
)
|
||||
const newcomer = admission === "same" ? sessionIDs[0] : Session.ID.make("ses_new_question")
|
||||
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
|
||||
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
Array.from(new Set([...sessionIDs, newcomer]), (sessionID) => ({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "question",
|
||||
directory: ref.directory,
|
||||
title: "Waiting question",
|
||||
version: "test",
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Form.Event.Created.type
|
||||
? Deferred.succeed(created, Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form).pipe(
|
||||
Effect.asVoid,
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const running = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
|
||||
const form = yield* Deferred.await(created)
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
|
||||
const created = yield* Deferred.make<void>()
|
||||
const newCreated = yield* Deferred.make<void>()
|
||||
const pending: Form.Info[] = []
|
||||
const interrupted: SessionEvent.Execution.Interrupted["data"][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type) {
|
||||
interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data))
|
||||
}
|
||||
if (event.type !== Form.Event.Created.type) return
|
||||
pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form)
|
||||
if (pending.length === count) yield* Deferred.succeed(created, undefined)
|
||||
if (pending.length > count) yield* Deferred.succeed(newCreated, undefined)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const running = yield* Effect.forEach(sessionIDs, (sessionID) =>
|
||||
execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach([...sessionIDs, newcomer], (sessionID) => execution.interrupt(sessionID)).pipe(
|
||||
Effect.andThen(TestClock.adjust("5 minutes")),
|
||||
),
|
||||
)
|
||||
yield* Deferred.await(created)
|
||||
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
|
||||
|
||||
// The first sweep discovers both cached graphs. No more Session events
|
||||
// are needed while the human is deciding how to answer.
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(yield* execution.isActive(sessionID)).toBe(true)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
expect(yield* forms.list({ sessionID })).toEqual([form])
|
||||
// Human input produces no durable activity while the question is pending.
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
// Interruption has cancelled each question, but slow cleanup still owns the graph.
|
||||
expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" })
|
||||
|
||||
if (settle === "answer") yield* forms.reply({ id: form.id, answer: { runtime: "Bun" } })
|
||||
if (settle === "cancel") yield* forms.cancel(form.id)
|
||||
if (settle === "interrupt") yield* execution.interrupt(sessionID)
|
||||
yield* Fiber.join(running)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
expect(yield* forms.state(form.id)).toEqual(
|
||||
settle === "answer" ? { status: "answered", answer: { runtime: "Bun" } } : { status: "cancelled" },
|
||||
)
|
||||
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
}),
|
||||
if (newWork) {
|
||||
yield* execution.wake(newcomer)
|
||||
if (admission === "other") yield* Deferred.await(newCreated)
|
||||
}
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
if (newWork) yield* Deferred.await(newCreated)
|
||||
const results = yield* Effect.forEach(running, Fiber.join)
|
||||
expect(results.every((exit) => exit._tag === "Failure")).toBe(true)
|
||||
expect(Array.from(yield* execution.active)).toEqual(newWork ? [newcomer] : [])
|
||||
expect(yield* store.listSuspended()).toEqual(newWork ? [newcomer] : [])
|
||||
expect(interrupted.toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
sessionIDs.map((sessionID) => ({ sessionID, reason: "inactivity" })),
|
||||
)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual(newWork ? [ref] : [])
|
||||
if (newWork) {
|
||||
expect(yield* forms.list({ sessionID: newcomer })).toEqual([pending[count]])
|
||||
if (admission === "same") {
|
||||
const later = LocationServiceMap.canonical({ directory: AbsolutePath.make("/later") })
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(later)), Effect.scoped)
|
||||
yield* TestClock.adjust("30 minutes")
|
||||
// Keep fresh work active while a different graph reaches its own deadline.
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID: newcomer }, { location: ref })
|
||||
yield* TestClock.adjust("32 minutes")
|
||||
expect(Array.from(yield* execution.active)).toEqual([newcomer])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
}
|
||||
yield* execution.interrupt(newcomer)
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
yield* execution.awaitIdle(newcomer)
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(yield* store.listSuspended()).toEqual([])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,8 +37,7 @@ const context = (id: string, system = fallback): SessionHooks["context"] => ({
|
||||
{ description: name, input: { type: "object" } },
|
||||
]),
|
||||
),
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {},
|
||||
})
|
||||
|
||||
describe("OptimizePlugin", () => {
|
||||
|
||||
@@ -124,8 +124,7 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
generation: {},
|
||||
providerOptions: {},
|
||||
options: {},
|
||||
})
|
||||
|
||||
type ToolErrorEvent = Extract<ToolHooks["execute.after"], { readonly status: "error" }>
|
||||
|
||||
@@ -240,22 +240,20 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
kind: "primary",
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agentID,
|
||||
model,
|
||||
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
return yield* requests.primary({
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agent: agentID,
|
||||
model,
|
||||
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
|
||||
system: [],
|
||||
messages: [],
|
||||
webSocket: "session",
|
||||
})
|
||||
}).pipe(
|
||||
|
||||
@@ -362,7 +362,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
prepare: modelRequests.compaction,
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_manual_compaction"),
|
||||
}),
|
||||
@@ -430,7 +430,7 @@ it.effect("manual compaction records model resolution failures without calling t
|
||||
modelID: Model.ID.make("missing"),
|
||||
}),
|
||||
),
|
||||
prepare: modelRequests.prepare,
|
||||
prepare: modelRequests.compaction,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
@@ -481,7 +481,7 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: () => Effect.succeed(loaded(session, messages)),
|
||||
prepare: modelRequests.prepare,
|
||||
prepare: modelRequests.compaction,
|
||||
messages,
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
|
||||
@@ -57,10 +57,12 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
|
||||
|
||||
for (const kind of KINDS) {
|
||||
const prepared = yield* requests.prepare({
|
||||
kind,
|
||||
scope: { session, agentID: Agent.ID.make("build"), model },
|
||||
transcript: { system: [], messages: [] },
|
||||
const prepared = yield* requests[kind]({
|
||||
session,
|
||||
agent: Agent.ID.make("build"),
|
||||
model,
|
||||
system: [],
|
||||
messages: [],
|
||||
})
|
||||
const http = prepared.options.http
|
||||
if (!http) throw new Error(`Expected HTTP middleware for ${kind}`)
|
||||
@@ -70,10 +72,13 @@ describe("SessionModelRequest HTTP hooks", () => {
|
||||
}
|
||||
|
||||
expect(seen).toEqual(
|
||||
KINDS.flatMap((kind) => [
|
||||
{ hook: "request", kind, agent: Agent.ID.make("build") },
|
||||
{ hook: "response", kind, agent: Agent.ID.make("build") },
|
||||
]),
|
||||
KINDS.flatMap((kind) => {
|
||||
const agent = Agent.ID.make(kind === "compaction" ? "compaction" : "build")
|
||||
return [
|
||||
{ hook: "request", kind, agent },
|
||||
{ hook: "response", kind, agent },
|
||||
]
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
@@ -1073,6 +1073,24 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("executes a tool renamed by a session context hook", function* (s) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.tools.renamed_echo = event.tools.echo!
|
||||
delete event.tools.echo
|
||||
}),
|
||||
)
|
||||
yield* s.admit("Use the renamed tool")
|
||||
yield* s.llm.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
|
||||
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
|
||||
expect(s.requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(s.executions).toEqual(["renamed"])
|
||||
})
|
||||
|
||||
scenario("executes the tool advertised before a registry reload", function* (s) {
|
||||
const registry = yield* Tool.Service
|
||||
const scope = yield* Scope.make()
|
||||
@@ -2308,7 +2326,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(event.model.variant).toBe(variant)
|
||||
event.system.push(SystemPart.make("Hook-provided instructions"))
|
||||
event.tools.echo.description = "Hook-provided tool description"
|
||||
event.generation.maxTokens = 4_000
|
||||
event.options.maxTokens = 4_000
|
||||
}),
|
||||
)
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { beforeEach, expect } from "bun:test"
|
||||
import { AIError, LLMClient, LLMEvent, LanguageModel, TransportError, type LLMRequest } from "@opencode-ai/ai"
|
||||
import {
|
||||
AIError,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LanguageModel,
|
||||
Message,
|
||||
SystemPart,
|
||||
TransportError,
|
||||
type LLMRequest,
|
||||
} from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
@@ -232,6 +241,39 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs title hooks instead of context hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_hook")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Redact this message")
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
let contexts = 0
|
||||
yield* hooks.register("session", "context", () => Effect.sync(() => contexts++))
|
||||
yield* hooks.register("session", "title", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event.sessionID).toBe(sessionID)
|
||||
expect(event.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
event.system.push(SystemPart.make("Prefer short titles."))
|
||||
event.messages = [Message.user("[redacted]")]
|
||||
event.options.maxTokens = 32
|
||||
event.options.reasoningEffort = "low"
|
||||
}),
|
||||
)
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generate(sessionID)
|
||||
|
||||
expect(contexts).toBe(0)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator.", "Prefer short titles."])
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Redact this message")
|
||||
expect(requests[0]?.generation).toEqual(expect.objectContaining({ maxTokens: 32 }))
|
||||
expect(requests[0]?.providerOptions).toEqual({ reasoningEffort: "low" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses a small model from the primary provider", () =>
|
||||
Effect.gen(function* () {
|
||||
selectedSmall = small
|
||||
|
||||
@@ -18,18 +18,25 @@ export interface SessionPrompt {
|
||||
delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
|
||||
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
|
||||
|
||||
export interface SessionRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
system: Array<SystemPart>
|
||||
messages: Array<Message>
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
/** Request overrides; unset fields retain route and model defaults. */
|
||||
generation: Types.DeepMutable<GenerationOptionsFields>
|
||||
providerOptions: Record<string, unknown>
|
||||
options: SessionRequestOptions
|
||||
}
|
||||
|
||||
export interface SessionContext extends SessionRequest {
|
||||
readonly agent: Agent.ID
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
/** Title generation is not an agent conversation and exposes no agent or tools. */
|
||||
export interface SessionTitle extends SessionRequest {}
|
||||
|
||||
/**
|
||||
* Why a Session request is being made. Auxiliary requests share the Session's
|
||||
* hook identity but need to be told apart from the agent loop.
|
||||
@@ -76,6 +83,7 @@ export interface SessionRetry {
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
|
||||
@@ -18,18 +18,25 @@ export interface SessionPrompt {
|
||||
delivery: SessionInbox.Delivery
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
/** Request overrides. Typed keys are generation settings; any other key is a provider option. */
|
||||
export type SessionRequestOptions = Types.DeepMutable<GenerationOptionsFields> & Record<string, unknown>
|
||||
|
||||
export interface SessionRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
system: Array<SystemPart>
|
||||
messages: Array<Message>
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
/** Request overrides; unset fields retain route and model defaults. */
|
||||
generation: Types.DeepMutable<GenerationOptionsFields>
|
||||
providerOptions: Record<string, unknown>
|
||||
options: SessionRequestOptions
|
||||
}
|
||||
|
||||
export interface SessionContext extends SessionRequest {
|
||||
readonly agent: Agent.ID
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
/** Title generation is not an agent conversation and exposes no agent or tools. */
|
||||
export interface SessionTitle extends SessionRequest {}
|
||||
|
||||
/**
|
||||
* Why a Session request is being made. Auxiliary requests share the Session's
|
||||
* hook identity but need to be told apart from the agent loop.
|
||||
@@ -76,6 +83,7 @@ export interface SessionRetry {
|
||||
export interface SessionHooks {
|
||||
readonly prompt: SessionPrompt
|
||||
readonly context: SessionContext
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
|
||||
@@ -233,7 +233,7 @@ export namespace Execution {
|
||||
export const Interrupted = Event.durable({
|
||||
type: "session.execution.interrupted",
|
||||
...options,
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) },
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded", "inactivity"]) },
|
||||
})
|
||||
export type Interrupted = typeof Interrupted.Type
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ it.live(
|
||||
)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.temperature = 0.25
|
||||
event.options.temperature = 0.25
|
||||
}),
|
||||
)
|
||||
yield* ctx.tool.transform((editor) =>
|
||||
|
||||
@@ -94,7 +94,7 @@ it.live(
|
||||
)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.temperature = config.temperature
|
||||
event.options.temperature = config.temperature
|
||||
}),
|
||||
)
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
|
||||
@@ -1089,7 +1089,7 @@ effect: (ctx) =>
|
||||
|
||||
### Sessions
|
||||
|
||||
Modify assembled system instructions, messages, or tools immediately before model dispatch.
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
|
||||
|
||||
```ts
|
||||
effect: (ctx) =>
|
||||
@@ -1099,11 +1099,14 @@ effect: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push({ text: "Keep the review focused on correctness." })
|
||||
delete event.tools.write
|
||||
event.options.maxTokens = 8_000
|
||||
}),
|
||||
)
|
||||
}),
|
||||
```
|
||||
|
||||
Title generation runs `title` instead, with the same shape minus `agent` and `tools`.
|
||||
|
||||
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` as
|
||||
the HTTP hooks below.
|
||||
|
||||
@@ -1178,6 +1181,7 @@ Context-overflow recovery remains separate because it compacts the conversation
|
||||
```ts
|
||||
interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly title: SessionTitle
|
||||
readonly "model.request": SessionModelRequest
|
||||
readonly "http.request": SessionHttpRequest
|
||||
readonly "http.response": SessionHttpResponse
|
||||
@@ -1195,6 +1199,32 @@ interface SessionRetry {
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
type SessionRequestOptions = {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
frequencyPenalty?: number
|
||||
presencePenalty?: number
|
||||
seed?: number
|
||||
stop?: string[]
|
||||
} & Record<string, unknown>
|
||||
|
||||
interface SessionRequest {
|
||||
readonly sessionID: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
system: SystemPart[]
|
||||
messages: Message[]
|
||||
options: SessionRequestOptions
|
||||
}
|
||||
|
||||
interface SessionContext extends SessionRequest {
|
||||
readonly agent: string
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
}
|
||||
|
||||
interface SessionTitle extends SessionRequest {}
|
||||
|
||||
interface SessionHookDomain {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -1195,28 +1195,29 @@ Keep prompt hooks retry-safe. They are not an exactly-once side-effect boundary:
|
||||
|
||||
#### Model context
|
||||
|
||||
Modify assembled system instructions, messages, tools, generation settings, or provider options immediately before model
|
||||
dispatch.
|
||||
Modify assembled system instructions, messages, tools, or request options immediately before model dispatch.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("context", (event) => {
|
||||
event.system.push({ text: "Keep the review focused on correctness." })
|
||||
delete event.tools.write
|
||||
event.generation.temperature = 0.2
|
||||
event.generation.maxTokens = 8_000
|
||||
event.options.temperature = 0.2
|
||||
event.options.maxTokens = 8_000
|
||||
})
|
||||
```
|
||||
|
||||
Context changes affect only the outgoing model call, not persisted history or
|
||||
configuration. The hook runs again for subsequent calls such as tool-driven
|
||||
continuations, transient session generation, and compaction, but not for title requests.
|
||||
continuations, transient session generation, and compaction. Title generation
|
||||
has its own hook.
|
||||
|
||||
Compaction context hooks receive the selected session agent. Its model-request
|
||||
and HTTP hooks retain the `compaction` agent identity for provider-specific handling.
|
||||
|
||||
Request overrides follow these rules:
|
||||
Request options follow these rules:
|
||||
|
||||
- `generation` and `providerOptions` start empty for each model call; they do not contain resolved model settings.
|
||||
- `options` starts empty for each model call; it does not contain resolved model settings.
|
||||
- Typed keys are generation settings; any other key is passed to the selected protocol as a provider option.
|
||||
- Hooks run in registration order and see overrides made by earlier hooks.
|
||||
- Request overrides take precedence over model defaults, which take precedence over route defaults.
|
||||
- Provider option objects merge recursively; arrays and scalar values replace earlier values.
|
||||
@@ -1232,7 +1233,7 @@ settings to the matching provider. For example, OpenAI Responses uses `reasoning
|
||||
await ctx.session.hook(
|
||||
"context",
|
||||
(event) => {
|
||||
event.providerOptions.reasoningEffort = "high"
|
||||
event.options.reasoningEffort = "high"
|
||||
},
|
||||
{ providerID: "openai" },
|
||||
)
|
||||
@@ -1243,6 +1244,17 @@ Generation options depend on the selected protocol and model:
|
||||
- `maxTokens` is the semantic output-token limit.
|
||||
- Gemini supports `topK`; OpenAI Responses does not expose it.
|
||||
|
||||
#### Title generation
|
||||
|
||||
Title generation is not an agent conversation, so its hook carries no `agent` or `tools`.
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("title", (event) => {
|
||||
event.system.push({ text: "Titles are at most five words." })
|
||||
event.options.maxTokens = 32
|
||||
})
|
||||
```
|
||||
|
||||
#### Model request
|
||||
|
||||
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind`
|
||||
@@ -1320,6 +1332,7 @@ import type { SessionPrompt } from "@opencode-ai/plugin/promise/session"
|
||||
interface SessionHooks {
|
||||
prompt: SessionPrompt
|
||||
context: SessionContextHook
|
||||
title: SessionTitleHook
|
||||
"model.request": SessionModelRequestHook
|
||||
"http.request": SessionHttpRequestHook
|
||||
"http.response": SessionHttpResponseHook
|
||||
@@ -1337,26 +1350,32 @@ interface SessionRetryHook {
|
||||
decision: RetryDecision
|
||||
}
|
||||
|
||||
interface SessionContextHook {
|
||||
type SessionRequestOptions = {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
frequencyPenalty?: number
|
||||
presencePenalty?: number
|
||||
seed?: number
|
||||
stop?: string[]
|
||||
} & Record<string, unknown>
|
||||
|
||||
interface SessionRequestHook {
|
||||
readonly sessionID: string
|
||||
readonly agent: string
|
||||
readonly model: { providerID: string; id: string; variant?: string }
|
||||
system: SystemPart[]
|
||||
messages: Message[]
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
generation: {
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
frequencyPenalty?: number
|
||||
presencePenalty?: number
|
||||
seed?: number
|
||||
stop?: string[]
|
||||
}
|
||||
providerOptions: Record<string, unknown>
|
||||
options: SessionRequestOptions
|
||||
}
|
||||
|
||||
interface SessionContextHook extends SessionRequestHook {
|
||||
readonly agent: string
|
||||
tools: Record<string, { description: string; input: JsonSchema }>
|
||||
}
|
||||
|
||||
interface SessionTitleHook extends SessionRequestHook {}
|
||||
|
||||
interface SessionHookContext {
|
||||
hook<Name extends keyof SessionHooks>(
|
||||
name: Name,
|
||||
@@ -1499,3 +1518,39 @@ Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
change.
|
||||
|
||||
## Support V1
|
||||
|
||||
A plugin can support V1 and V2 from the same package entrypoint. Default export
|
||||
one object with a V1 `server()` function and a V2 `setup()` function:
|
||||
|
||||
```ts title="src/index.ts"
|
||||
import { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default {
|
||||
...Plugin.define({
|
||||
id: "example",
|
||||
async setup(ctx) {
|
||||
await ctx.tool.hook("execute.before", () => {
|
||||
console.log("A tool is about to run")
|
||||
})
|
||||
},
|
||||
}),
|
||||
async server() {
|
||||
return {
|
||||
"tool.execute.before": async () => {
|
||||
console.log("A tool is about to run")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- V1 calls `server()` and uses the returned hooks.
|
||||
- V2 reads the default export's `id` and `setup()` (or `effect()` for Effect plugins), ignoring `server()`.
|
||||
- Keep each implementation on its own API; sharing an export does not translate V1 hooks into V2 hooks.
|
||||
- Spread `Plugin.define(...)` into the exported object so it type-checks the V2 definition separately from `server()`.
|
||||
|
||||
The V1 object form is supported in OpenCode `1.18.29`. Older V1 releases may
|
||||
expect function exports instead; test the installed package with the oldest V1
|
||||
release you intend to support and with V2.
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "Go"
|
||||
description: "Low cost subscription for open coding models."
|
||||
---
|
||||
|
||||
OpenCode Go is a low cost subscription — **$5 for your first month**, then **$10/month** — that gives you reliable access to popular open coding models.
|
||||
|
||||
Go works like any other provider in OpenCode. You subscribe to OpenCode Go and get your API key. It's **completely optional** and you don't need it to use OpenCode.
|
||||
|
||||
It is designed primarily for international users and provides stable global access.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Sign in to the [OpenCode console](https://console.opencode.ai), subscribe to Go, add your billing details, and copy your API key.
|
||||
2. Run `/connect` in the TUI, select **OpenCode Go**, and paste your API key.
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Run `/models` to select a model available through Go.
|
||||
|
||||
```text
|
||||
/models
|
||||
```
|
||||
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go.</Callout>
|
||||
|
||||
The current list of models includes:
|
||||
|
||||
- **Grok 4.5**
|
||||
- **GLM-5.2**
|
||||
- **GLM-5.1**
|
||||
- **GPT 5.6 Luna**
|
||||
- **Kimi K3**
|
||||
- **Kimi K2.7 Code**
|
||||
- **Kimi K2.6**
|
||||
- **MiMo-V2.5**
|
||||
- **MiMo-V2.5-Pro**
|
||||
- **MiniMax M3**
|
||||
- **MiniMax M2.7**
|
||||
- **Qwen3.8 Max**
|
||||
- **Qwen3.7 Max**
|
||||
- **Qwen3.7 Plus**
|
||||
- **Qwen3.6 Plus**
|
||||
- **DeepSeek V4 Pro**
|
||||
- **DeepSeek V4 Flash**
|
||||
- **Hy3**
|
||||
|
||||
The list of models may change as we test and add new ones.
|
||||
|
||||
## Usage limits
|
||||
|
||||
OpenCode Go includes the following limits:
|
||||
|
||||
- **5 hour limit** — $12 of usage
|
||||
- **Weekly limit** — $30 of usage
|
||||
- **Monthly limit** — $60 of usage
|
||||
|
||||
Limits are defined in dollar value. Your actual request count depends on the model you use. Cheaper models like DeepSeek V4 Flash allow for more requests, while higher-cost models like GLM-5.2 allow for fewer.
|
||||
|
||||
The table below provides an estimated request count based on typical Go usage patterns:
|
||||
|
||||
| Model | Requests per 5 hours | Requests per week | Requests per month |
|
||||
| ----------------- | -------------------- | ----------------- | ------------------ |
|
||||
| Grok 4.5 | 120 | 300 | 600 |
|
||||
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
|
||||
| GLM-5.2 | 880 | 2,150 | 4,300 |
|
||||
| GLM-5.1 | 880 | 2,150 | 4,300 |
|
||||
| Kimi K3 | 110 | 250 | 490 |
|
||||
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
|
||||
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
|
||||
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
|
||||
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
|
||||
| Qwen3.8 Max | 160 | 400 | 810 |
|
||||
| Qwen3.7 Max | 340 | 840 | 1,690 |
|
||||
| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 |
|
||||
| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 |
|
||||
| DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 |
|
||||
| DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 |
|
||||
| Hy3 | 4,300 | 10,750 | 21,500 |
|
||||
|
||||
The estimates are based on observed request patterns:
|
||||
|
||||
- Grok 4.5 — 1,100 input, 71,500 cached, 220 output tokens per request
|
||||
- GLM-5.2/5.1 — 700 input, 52,000 cached, 150 output tokens per request
|
||||
- GPT 5.6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
|
||||
- Kimi K3 — 1,050 input, 76,500 cached, 300 output tokens per request
|
||||
- Kimi K2.7/K2.6 — 870 input, 55,000 cached, 200 output tokens per request
|
||||
- DeepSeek V4 Pro — 750 input, 82,000 cached, 290 output tokens per request
|
||||
- DeepSeek V4 Flash — 790 input, 68,000 cached, 280 output tokens per request
|
||||
- MiniMax M3 — 510 input, 56,000 cached, 190 output tokens per request
|
||||
- MiniMax M2.7 — 300 input, 55,000 cached, 125 output tokens per request
|
||||
- MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens per request
|
||||
- MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens per request
|
||||
- Qwen3.8 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Hy3 — 830 input, 71,500 cached, 295 output tokens per request
|
||||
|
||||
The estimates are also based on the following prices per 1M tokens and the monthly usage included with each model:
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model pricing" tabIndex={0}>
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write | Usage |
|
||||
| ---------------------------- | ------ | ------ | ----------- | ------------ | ----- |
|
||||
| Grok 4.5 | $2.00 | $6.00 | $0.30 | - | $15 |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | $15 |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | $15 |
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | $60 |
|
||||
| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | $60 |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | $15 |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | $60 |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | $60 |
|
||||
| MiMo V2.5 | $0.14 | $0.28 | $0.0028 | - | $60 |
|
||||
| MiMo V2.5 Pro | $0.435 | $0.87 | $0.003625 | - | $15 |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | $60 |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | $60 |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | $60 |
|
||||
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | $15 |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | $60 |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | $60 |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | $60 |
|
||||
| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | $60 |
|
||||
| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 |
|
||||
| DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 |
|
||||
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 |
|
||||
| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 |
|
||||
|
||||
</div>
|
||||
|
||||
You can track your current usage in the [console](https://console.opencode.ai).
|
||||
|
||||
<Callout type="tip">If you reach the usage limit, you can continue using the free models.</Callout>
|
||||
|
||||
Usage limits may change as we learn from early usage and feedback.
|
||||
|
||||
### Usage beyond limits
|
||||
|
||||
If you also have credits on your Console balance, you can enable the **Use balance** option in the console. When enabled, Go will fall back to your [pay-as-you-go balance](/console/models#pricing) after you've reached your usage limits instead of blocking requests.
|
||||
|
||||
### Why some models have lower usage
|
||||
|
||||
With Go, you pay $10/month and we aim to give you 6x that in usage.
|
||||
|
||||
- For most models, we make this work through bulk discounts and reserved GPU capacity. We pass those savings on to you through the 6x multiplier.
|
||||
- For some models, we haven't had the opportunity to negotiate a discount or host them at a lower cost, either because the model is new or because their public pricing is already discounted.
|
||||
- For these models, you still get a little more than if you paid the model providers directly. This is why their usage multiplier is lower in the table above.
|
||||
|
||||
## Endpoints
|
||||
|
||||
You can also access Go models through the following API endpoints.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model endpoints" tabIndex={0}>
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ----------------- | ----------------- | ------------------------------------------------ | --------------------------- |
|
||||
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
|
||||
| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 | mimo-v2.5 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
</div>
|
||||
|
||||
These AI SDK packages are for applications calling Go directly. To use Go in OpenCode, connect with `/connect` and set
|
||||
the [model](/models) using the format `opencode-go/<model-id>`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "opencode-go/kimi-k3",
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
Fetch the full list of available models and their metadata from the models endpoint:
|
||||
|
||||
```bash
|
||||
curl https://opencode.ai/zen/go/v1/models
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
| Model | Model training | Data retention |
|
||||
| ----------------- | -------------- | -------------- |
|
||||
| Grok 4.5 | Not used | 30 days |
|
||||
| GPT 5.6 Luna | Not used | 30 days |
|
||||
| GLM-5.2 | Not used | 0 days |
|
||||
| GLM-5.1 | Not used | 0 days |
|
||||
| Kimi K3 | Not used | 0 days |
|
||||
| Kimi K2.7 Code | Not used | 0 days |
|
||||
| Kimi K2.6 | Not used | 0 days |
|
||||
| MiMo-V2.5-Pro | Not used | 0 days |
|
||||
| MiMo-V2.5 | Not used | 0 days |
|
||||
| Qwen3.8 Max | Not used | 0 days |
|
||||
| Qwen3.7 Max | Not used | 0 days |
|
||||
| Qwen3.7 Plus | Not used | 0 days |
|
||||
| Qwen3.6 Plus | Not used | 0 days |
|
||||
| MiniMax M3 | Not used | 0 days |
|
||||
| MiniMax M2.7 | Not used | 0 days |
|
||||
| DeepSeek V4 Pro | Not used | 0 days |
|
||||
| DeepSeek V4 Flash | Not used | 0 days |
|
||||
| Hy3 | Not used | 0 days |
|
||||
|
||||
- **Grok 4.5:** 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 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).
|
||||
- **DeepSeek V4 Flash:** ZDR agreement is renewed monthly. The current agreement is valid through August 31, 2026.
|
||||
|
||||
## Background
|
||||
|
||||
Open models have gotten really good. They now reach performance close to proprietary models for coding tasks. Because many providers can serve them competitively, they are usually far cheaper.
|
||||
|
||||
However, getting reliable, low latency access to them can be difficult. Providers vary in quality and availability.
|
||||
|
||||
<Callout type="tip">We tested a select group of models and providers that work well with OpenCode.</Callout>
|
||||
|
||||
To fix this, we did a couple of things:
|
||||
|
||||
1. We tested a select group of open models and talked to their teams about how to best run them.
|
||||
2. We worked with a few providers to make sure these were being served correctly.
|
||||
3. We benchmarked the combination of the model/provider and came up with a list that we feel good recommending.
|
||||
|
||||
OpenCode Go gives you access to these models for **$5 for your first month**, then **$10/month**.
|
||||
|
||||
## Goals
|
||||
|
||||
We created OpenCode Go to:
|
||||
|
||||
1. Make AI coding **accessible** to more people with a low cost subscription.
|
||||
2. Provide **reliable** access to the best open coding models.
|
||||
3. Curate models that are **tested and benchmarked** for coding agent use.
|
||||
4. Have **no lock-in** by allowing you to use any other provider with OpenCode as well.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: "Intro"
|
||||
---
|
||||
|
||||
The [OpenCode Console](https://opencode.ai/console) is an optional service that provides additional benefits to
|
||||
using OpenCode particularly as a team
|
||||
|
||||
- Inference for both proprietary and open source models
|
||||
- LLM Gateway for connecting your own providers
|
||||
- Usage tracking and budget controls
|
||||
- Deploy team wide policies to control OpenCode behavior
|
||||
- Web search
|
||||
- OpenCode Go, $10 subscription for open source model access
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: "Models"
|
||||
description: "Curated coding models, pay-as-you-go pricing, and API access through OpenCode Console."
|
||||
---
|
||||
|
||||
OpenCode Console provides a list of models tested and verified by the OpenCode team. Sign in, add credits, and get an
|
||||
API key to use them with OpenCode or another coding agent.
|
||||
|
||||
Console works like any other provider in OpenCode. It's **completely optional**, and you can use any other provider
|
||||
instead. For a subscription with included usage, see [OpenCode Go](/console/go).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Sign in to the [console](https://console.opencode.ai), add your billing details and credits, and copy your API key.
|
||||
2. Run `/connect` in the TUI, choose the OpenCode pay-as-you-go provider, and paste your API key.
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Run `/models` to see the available models and select one.
|
||||
|
||||
```text
|
||||
/models
|
||||
```
|
||||
|
||||
You are charged per request and can add credits to your account.
|
||||
|
||||
## Endpoints
|
||||
|
||||
You can also access the models directly through the following API endpoints.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Console model endpoints" tabIndex={0}>
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ---------------------- | ---------------------- | --------------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.6 Sol | gpt-5.6-sol | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.6 Terra | gpt-5.6-terra | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.5 | gpt-5.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.5 Pro | gpt-5.5-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Mini | gpt-5.4-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 Nano | gpt-5.4-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3.6 Flash | gemini-3.6-flash | `https://opencode.ai/zen/v1/models/gemini-3.6-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.5 Flash Lite | gemini-3.5-flash-lite | `https://opencode.ai/zen/v1/models/gemini-3.5-flash-lite` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Grok 4.5 | grok-4.5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Grok Build 0.1 | grok-build-0.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| DeepSeek V4 Pro | deepseek-v4-pro | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash | deepseek-v4-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.2 | glm-5.2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `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` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-tiny Free | ling-3.0-tiny-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| LongCat-2.0 Free | longcat-2.0-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
</div>
|
||||
|
||||
These AI SDK packages are for applications calling the endpoints directly. To select a [model](/models) in OpenCode,
|
||||
use the format `opencode/<model-id>`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "opencode/gpt-5.5",
|
||||
}
|
||||
```
|
||||
|
||||
### Model list
|
||||
|
||||
Fetch the full list of available models and their metadata from the models endpoint:
|
||||
|
||||
```bash
|
||||
curl https://opencode.ai/zen/v1/models
|
||||
```
|
||||
|
||||
## Pricing
|
||||
|
||||
Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Console model pricing" tabIndex={0}>
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------- | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-tiny Free | Free | Free | Free | - |
|
||||
| LongCat-2.0 Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| GLM 5.2 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5.1 | $1.40 | $4.40 | $0.26 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 |
|
||||
| Qwen3.7 Plus | $0.40 | $1.60 | $0.04 | $0.50 |
|
||||
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
|
||||
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||
| DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - |
|
||||
| DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - |
|
||||
| Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 |
|
||||
| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 |
|
||||
| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Gemini 3.6 Flash | $1.50 | $7.50 | $0.15 | - |
|
||||
| Gemini 3.5 Flash | $1.50 | $9.00 | $0.15 | - |
|
||||
| Gemini 3.5 Flash Lite | $0.30 | $2.50 | $0.03 | - |
|
||||
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| Grok 4.5 (≤ 200K tokens) | $2.00 | $6.00 | $0.30 | - |
|
||||
| Grok 4.5 (> 200K tokens) | $4.00 | $12.00 | $0.60 | - |
|
||||
| Grok Build 0.1 | $1.00 | $2.00 | $0.20 | - |
|
||||
| GPT 5.6 Sol (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | $6.25 |
|
||||
| GPT 5.6 Sol (> 272K tokens) | $10.00 | $45.00 | $1.00 | $12.50 |
|
||||
| GPT 5.6 Terra (≤ 272K tokens) | $2.00 | $12.00 | $0.20 | $2.50 |
|
||||
| GPT 5.6 Terra (> 272K tokens) | $4.00 | $18.00 | $0.40 | $5.00 |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 |
|
||||
| GPT 5.5 (≤ 272K tokens) | $5.00 | $30.00 | $0.50 | - |
|
||||
| GPT 5.5 (> 272K tokens) | $10.00 | $45.00 | $1.00 | - |
|
||||
| GPT 5.5 Pro | $30.00 | $180.00 | $30.00 | - |
|
||||
| GPT 5.4 (≤ 272K tokens) | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.4 (> 272K tokens) | $5.00 | $22.50 | $0.50 | - |
|
||||
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
|
||||
| GPT 5.4 Mini | $0.75 | $4.50 | $0.075 | - |
|
||||
| GPT 5.4 Nano | $0.20 | $1.25 | $0.02 | - |
|
||||
| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | $0.05 | $0.40 | $0.005 | - |
|
||||
|
||||
</div>
|
||||
|
||||
You may notice [low-cost models](/models), such as Haiku, Nano, or Flash, in your usage history. OpenCode uses these
|
||||
models to generate session titles.
|
||||
|
||||
<Callout>
|
||||
Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don't charge anything beyond that.
|
||||
</Callout>
|
||||
|
||||
### Free models
|
||||
|
||||
These models are available for a limited time while their teams collect feedback and improve them:
|
||||
|
||||
- DeepSeek V4 Flash Free
|
||||
- MiMo-V2.5 Free
|
||||
- Laguna S 2.1 Free
|
||||
- Ling-3.0-tiny Free
|
||||
- LongCat-2.0 Free
|
||||
- North Mini Code Free
|
||||
- Nemotron 3 Ultra Free
|
||||
- Big Pickle, a stealth model
|
||||
|
||||
[Contact us](mailto:help@anoma.ly) if you have any questions.
|
||||
|
||||
### Auto-reload
|
||||
|
||||
If your balance goes below $5, Console automatically reloads $20. You can change the auto-reload amount or disable
|
||||
auto-reload entirely.
|
||||
|
||||
### Monthly limits
|
||||
|
||||
You can set a monthly usage limit for the entire workspace and for each member of your team.
|
||||
|
||||
For example, with a $20 monthly usage limit, Console will not use more than $20 in a month. If auto-reload is enabled,
|
||||
you might still be charged more than $20 when your balance goes below $5.
|
||||
|
||||
### Deprecated models
|
||||
|
||||
| Model | Deprecation date |
|
||||
| ------------------ | ----------------- |
|
||||
| GPT 5.2 Codex | July 23, 2026 |
|
||||
| GPT 5.1 Codex | July 23, 2026 |
|
||||
| GPT 5.1 Codex Max | July 23, 2026 |
|
||||
| GPT 5.1 Codex Mini | July 23, 2026 |
|
||||
| GPT 5 Codex | July 23, 2026 |
|
||||
| Claude Opus 4.1 | August 5, 2026 |
|
||||
| Claude Sonnet 4 | June 15, 2026 |
|
||||
| Claude Haiku 3.5 | February 16, 2026 |
|
||||
| Gemini 3 Pro | March 9, 2026 |
|
||||
| MiniMax M2.5 | August 5, 2026 |
|
||||
| MiniMax M2.1 | March 15, 2026 |
|
||||
| GLM 5 | May 14, 2026 |
|
||||
| GLM 4.7 | March 15, 2026 |
|
||||
| GLM 4.6 | March 15, 2026 |
|
||||
| Kimi K2.5 | August 5, 2026 |
|
||||
| Kimi K2 Thinking | March 6, 2026 |
|
||||
| Kimi K2 | March 6, 2026 |
|
||||
| Qwen3 Coder 480B | February 6, 2026 |
|
||||
|
||||
## Privacy
|
||||
|
||||
All these models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model
|
||||
training, with the following exceptions:
|
||||
|
||||
- **Big Pickle:** During its free period, collected data may be used to improve the model.
|
||||
- **DeepSeek V4 Flash Free:** During its free period, collected data may be used to improve the model.
|
||||
- **MiMo-V2.5 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **Laguna S 2.1 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **Ling-3.0-tiny Free:** During its free period, collected data may be used to improve the model.
|
||||
- **LongCat-2.0 Free:** During its free period, collected data may be used to improve the model.
|
||||
- **North Mini Code Free:** During its free period, collected data may be retained and used to improve the model. Do not submit personal or confidential data. See the provider's [Terms of Use](https://cohere.com/terms-of-use) and [Privacy Policy](https://cohere.com/privacy).
|
||||
- **Nemotron 3 Ultra Free (NVIDIA free endpoints):** Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about data processing practices, see the [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to the collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- **OpenAI APIs:** Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- **Anthropic APIs:** Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
For subscription models, see [Go privacy](/console/go#privacy).
|
||||
|
||||
## For teams
|
||||
|
||||
You can invite teammates, assign roles, and curate the models your team uses.
|
||||
|
||||
<Callout>
|
||||
Managing workspaces is currently free for teams as part of the beta. More pricing details will be shared later.
|
||||
</Callout>
|
||||
|
||||
### Roles
|
||||
|
||||
Invite teammates to your workspace and assign roles:
|
||||
|
||||
- **Admin:** Manage models, members, API keys, and billing.
|
||||
- **Member:** Manage only their own API keys.
|
||||
|
||||
Admins can also set monthly spending limits for each member to keep costs under control.
|
||||
|
||||
### Model access
|
||||
|
||||
Admins can enable or disable specific models for the workspace. Requests made to a disabled model return an error.
|
||||
|
||||
For example, you can disable a model that collects data so members of your workspace cannot use it.
|
||||
|
||||
### Bring your own key
|
||||
|
||||
You can use your own OpenAI or Anthropic API keys while still accessing other models through Console. Tokens used
|
||||
with your own keys are billed directly by the provider.
|
||||
|
||||
For example, if your organization already has an OpenAI API key, you can use it instead of the one Console provides.
|
||||
|
||||
## Background
|
||||
|
||||
There are many models available, but only a few work well as coding agents. Providers also vary in configuration,
|
||||
performance, and quality, so a model accessed through a gateway such as OpenRouter may perform differently.
|
||||
|
||||
<Callout type="tip">We tested a select group of models and providers that work well with OpenCode.</Callout>
|
||||
|
||||
To build this catalog, we:
|
||||
|
||||
1. Tested a select group of models and talked to their teams about how to best run them.
|
||||
2. Worked with providers to make sure the models were served correctly.
|
||||
3. Benchmarked each model/provider combination to produce a list we feel good recommending.
|
||||
|
||||
Console is an AI gateway that gives you access to these models.
|
||||
|
||||
## Goals
|
||||
|
||||
We built Console to:
|
||||
|
||||
1. **Benchmark** the best models and providers for coding agents.
|
||||
2. Provide the **highest quality** options without downgrading performance or routing to cheaper providers.
|
||||
3. Pass along **price drops** by selling at cost, with markup only to cover processing fees.
|
||||
4. Have **no lock-in**: use Console with any coding agent, or use another provider with OpenCode.
|
||||
@@ -35,9 +35,9 @@ directly [in the TUI](/cli/providers) with `/connect`.
|
||||
See [Providers](/providers) to configure custom providers.
|
||||
|
||||
If you'd like easy access to all the best coding models you can try out
|
||||
[OpenCode Console](https://console.opencode.ai).
|
||||
[OpenCode Console](/console).
|
||||
|
||||
You can also try [OpenCode Go](https://opencode.ai/go) a $10/month subscription
|
||||
You can also try [OpenCode Go](/console/go) a $10/month subscription
|
||||
plan that grants you access to the best open source models.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,6 +38,19 @@ The `providers` object is keyed by provider ID. Each provider accepts these fiel
|
||||
| `body` | JSON fields merged into request bodies. |
|
||||
| `models` | Models to add or override, keyed by catalog model ID. |
|
||||
|
||||
## OpenCode Go
|
||||
|
||||
[OpenCode Go](/console/go) is an optional subscription that provides access to coding models tested by the OpenCode team.
|
||||
Subscribe in the [console](https://console.opencode.ai), copy your API key, then run `/connect` in the TUI and select
|
||||
**OpenCode Go**:
|
||||
|
||||
```text
|
||||
/connect
|
||||
```
|
||||
|
||||
Paste your API key, then run `/models` to select a Go model. See the [Go guide](/console/go) for setup, usage limits,
|
||||
endpoints, and privacy details.
|
||||
|
||||
## Azure OpenAI and Microsoft Foundry
|
||||
|
||||
Azure supports either an API key or your existing Microsoft Entra ID session from the Azure CLI.
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface DocsNavGroup {
|
||||
}
|
||||
|
||||
export interface DocsSection {
|
||||
key: "docs" | "cli" | "build" | "api"
|
||||
key: "docs" | "cli" | "build" | "api" | "console"
|
||||
title: string
|
||||
landingSlug: string
|
||||
groups: DocsNavGroup[]
|
||||
@@ -131,6 +131,20 @@ export const docsSections: DocsSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "console",
|
||||
title: "Console",
|
||||
landingSlug: "console",
|
||||
groups: [
|
||||
{
|
||||
items: [
|
||||
{ title: "Intro", slug: "console" },
|
||||
{ title: "Models", slug: "console/models" },
|
||||
{ title: "Go", slug: "console/go" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function docsHref(slug: string, anchor?: string) {
|
||||
|
||||
@@ -904,6 +904,10 @@ main {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
Reference in New Issue
Block a user