Compare commits

..
24 Commits
Author SHA1 Message Date
opencode-agent[bot]andBrendonovich bf97a0443f fix(app): alert only for open session tabs (#52204)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-30 03:55:01 +00:00
Luke Parker 67b14e9191 feat(session-ui): keep open Used group header sticky (#52210) 2026-09-30 13:46:40 +10:00
Luke Parker 1b02abcfab feat(session-ui): group adjacent reads into one row (#52207) 2026-09-30 12:54:40 +10:00
Aiden Cline 74dbc509d7 fix(ai): place prompt cache breakpoints on OpenRouter Anthropic and Qwen requests (#52110) 2026-09-29 18:07:49 -05:00
Aiden Cline 760f87fab1 fix(core): pass through Copilot Responses settings (#52182) 2026-09-29 17:52:53 -05:00
opencode-agent[bot]andrekram1-node df51166711 fix(ci): run V2 Discord notification after skipped build jobs (#52012)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-29 16:22:16 -05:00
opencode 8d8a7bc844 sync release versions for v2.0.20 2026-09-29 21:08:02 +00:00
opencode-agent[bot] a2fc1968be fix(stats): match document background to page theme (#52169) 2026-09-29 15:59:22 -05:00
Aiden Cline c2a39ac592 fix(core): stop promising a ChatGPT usage reset (#52168) 2026-09-29 15:50:09 -05:00
Aiden Cline 4deda18037 fix(app): align ChatGPT usage copy and links (#52163) 2026-09-29 15:18:45 -05:00
Aiden Cline 0f83038253 fix(core): align ChatGPT token sharing with updated partner guide (#52162) 2026-09-29 15:11:58 -05:00
Dax 4c33a253aa feat(cli): add auth export and auth import commands (#52139) 2026-09-29 16:09:01 -04:00
135995d4fc test: stabilize V2 CI test suites (#52159)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-29 14:54:11 -05:00
Aiden Cline 4d9090790b feat(core): add separate ChatGPT token-sharing OAuth method (#52147) 2026-09-29 14:48:51 -05:00
Aiden Cline 621a5b0706 feat(app): show ChatGPT token-sharing guidance (#52160) 2026-09-29 14:45:59 -05:00
Aiden Cline 16ef62c851 feat(app): show ChatGPT usage limit dialog (#52153) 2026-09-29 14:38:48 -05:00
Aiden Cline b694d2bf26 fix(core): explain ChatGPT token-sharing errors (#52158) 2026-09-29 14:25:01 -05:00
Aiden Cline 89d37a0f6d fix(core): restrict database files to their owner (#52150) 2026-09-29 14:18:40 -05:00
James Long bc5ff1cfd9 fix(cli): answer subagent asks in run and keep parallel rejections continuing (#52146) 2026-09-29 15:03:06 -04:00
Aiden Cline c14a1f5acf feat(core): preserve provider response body in session errors (#52136) 2026-09-29 13:32:09 -05:00
Frank 7db941a892 docs(console): add GPT 6.1 Sol to model list 2026-09-29 14:28:47 -04:00
James Long 83fafda63a feat(cli): continue run after rejecting a permission ask (#51968) 2026-09-29 14:26:20 -04:00
opencode-agent[bot]andjlongster a565ea8c74 feat(cli): allow disabling the background service (#52107)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-29 13:32:38 -04:00
Aiden Cline 3126808e0e fix(ai): defer Mistral thinking metadata until block end (#52008) 2026-09-29 11:38:06 -05:00
120 changed files with 4077 additions and 769 deletions
+1 -1
View File
@@ -662,7 +662,7 @@ jobs:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
if: ${{ !cancelled() && github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
+36 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.19",
"version": "2.0.20",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -178,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -204,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -377,6 +377,7 @@
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
@@ -409,7 +410,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -458,7 +459,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -495,7 +496,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -511,7 +512,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -530,7 +531,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -543,7 +544,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -557,7 +558,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -572,7 +573,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -611,7 +612,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -641,7 +642,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -656,7 +657,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -680,7 +681,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -701,7 +702,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -723,7 +724,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -758,7 +759,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -778,7 +779,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -812,7 +813,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -831,7 +832,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -877,7 +878,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -891,7 +892,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +926,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +961,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -998,7 +999,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1039,7 +1040,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+15 -3
View File
@@ -51,6 +51,17 @@ const RESPECTS_INLINE_HINTS = new Set([
"openrouter",
])
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
// breakpoint, so a conversation-tail breakpoint writes a new cache every step and costs more than none. Qwen ignores
// breakpoints on tool definitions and caches tools with the system prompt.
const openRouterPolicy = (modelID: string): CachePolicyObject => {
// `~anthropic/claude-sonnet-latest` style IDs are OpenRouter aliases for the latest model in a family.
const id = modelID.replace(/^~/, "")
if (id.startsWith("anthropic/")) return AUTO
if (id.startsWith("qwen/")) return { system: true, messages: { tail: 1 } }
return NONE
}
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -156,9 +167,10 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto"))
return request
const policy = resolve(request.cache)
const policy =
request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")
? openRouterPolicy(request.model.id)
: resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
+10 -6
View File
@@ -426,6 +426,7 @@ interface ActiveContent {
readonly type: "text" | "reasoning"
readonly id: string
readonly thinking?: MistralThinkingContent
readonly thinkingUnits?: MistralThinkingUnit[]
}
export interface ParserState {
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
thinkingText(state.active.thinkingUnits ?? []),
)
return { ...state, lifecycle, active: undefined }
}
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
const units = thinkingUnits(part.thinking)
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
// Keep native units out of streamed events until the block is complete.
const accumulated = active.thinkingUnits ?? []
accumulated.push(...units)
const thinking = {
...active.thinking,
...part,
type: "thinking" as const,
thinking: [...(active.thinking?.thinking ?? []), ...units],
thinking: [],
}
const text = thinkingText(units)
return {
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
active: { ...active, thinking, thinkingUnits: accumulated },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
+47 -40
View File
@@ -20,7 +20,6 @@ import {
type LLMRequest,
type MediaPart,
type ReasoningPart,
type TextPart,
type ToolCallPart,
type ToolDefinition,
} from "../schema/index.js"
@@ -45,6 +44,7 @@ const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
type OpenAIChatCacheControl = Schema.Schema.Type<typeof OpenAIChatCacheControl>
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
@@ -121,12 +121,15 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
const OpenAIChatTextContent = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTextContent = Schema.Schema.Type<typeof OpenAIChatTextContent>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
OpenAIChatTextContent,
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -136,6 +139,7 @@ const OpenAIChatUserContent = Schema.Union([
file: Schema.Struct({ filename: Schema.String, file_data: Schema.String }),
}),
])
type OpenAIChatUserContent = Schema.Schema.Type<typeof OpenAIChatUserContent>
const OpenAIChatMessage = Schema.Union([
Schema.Struct({
@@ -149,21 +153,19 @@ const OpenAIChatMessage = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
content: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)])),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatTextContent)]),
}),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -327,9 +329,7 @@ export interface ParserState {
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly cacheControl?: (cache: CacheHint | undefined) => OpenAIChatCacheControl | undefined
readonly toolCallID?: (id: string) => string
}
@@ -415,7 +415,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const content: OpenAIChatUserContent[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
@@ -441,14 +441,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
requireReasoning: boolean,
options: LoweringOptions & { readonly providerMetadataKey: string },
) {
const content: TextPart[] = []
const content: OpenAIChatTextContent[] = []
const reasoning: ReasoningPart[] = []
const toolCalls: OpenAIChatAssistantToolCall[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "reasoning", "tool-call"])
if (part.type === "text") {
content.push(part)
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
continue
}
if (part.type === "reasoning") {
@@ -486,14 +486,15 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = {
role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "",
content: (() => {
if (content.some((part) => part.cache_control !== undefined)) return content
if (content.length === 0 && toolCalls.length > 0) return null
return content.map((part) => part.text).join("")
})(),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
...(details !== undefined ? { reasoning_details: details } : {}),
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
@@ -504,33 +505,38 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = []
const attachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const attachments: OpenAIChatUserContent[] = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
messages.push(
toolMessage(
options.toolCallID?.(part.id) ?? part.id,
ProviderShared.toolResultText(part),
options.cacheControl?.(part.cache),
),
)
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
messages.push(
toolMessage(options.toolCallID?.(part.id) ?? part.id, text.join("\n"), options.cacheControl?.(part.cache)),
)
const files = content.filter((item) => item.type === "file")
attachments.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
}
return { messages, attachments }
})
// Chat cache breakpoints belong on text content parts, not on the message itself.
const toolMessage = (toolCallID: string, text: string, cacheControl: OpenAIChatCacheControl | undefined) => ({
role: "tool" as const,
tool_call_id: toolCallID,
content: cacheControl === undefined ? text : [{ type: "text" as const, text, cache_control: cacheControl }],
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
@@ -589,7 +595,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingAttachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const pendingAttachments: OpenAIChatUserContent[] = []
const flushAttachments = () => {
if (pendingAttachments.length === 0) return
bridgeTools()
@@ -599,24 +605,25 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
const cacheControl = options.cacheControl?.(part.cache)
if (pendingAttachments.length > 0) {
messages.push({
role: "user",
content: [
...pendingAttachments.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
})
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = options.cacheControl?.(part.cache)
messages[messages.length - 1] = cacheControl
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
@@ -625,15 +632,15 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
role: "user",
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
{ type: "text", text: part.text, cache_control: cacheControl },
],
}
else
messages.push(
options.cacheControl?.(part.cache)
cacheControl
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
content: [{ type: "text", text: part.text, cache_control: cacheControl }],
}
: { role: "user", content: part.text },
)
@@ -4,7 +4,7 @@
"model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"],
"name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z"
"recordedAt": "2026-09-29T16:19:02.835Z"
},
"interactions": [
{
@@ -15,14 +15,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"temperature\":0,\"reasoning\":{\"max_tokens\":1024},\"max_completion_tokens\":1536,\"store\":false,\"usage\":{\"include\":true}}"
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Think through the arithmetic, then reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":1536,\"temperature\":0,\"usage\":{\"include\":true},\"reasoning\":{\"max_tokens\":1024}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"Ev8CCrIBCBIYAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhN3oedXByb2ZfMDExQ2VkSE1TN0tEcTM4Z2JkUkp0bXN3qAH2ye/VBhIMhjcPOnE2wLhs0P05GgwA3z9luwbhnSyRMSQiMDYeIIThirdkJb/n2s2TxUk5EOMrSc1xejAb0RN7BIl66P5KhtwXvcZTiyCkBS8+7Cp6MOinWi+I8U7DhWskSA0pHZqaAncbV7qCIHkoZ5aT8jiPWplAOnlciDo2tLkGsLnzXrnfhI11GTF0kcfyhbrt3tjEDSiPstBbT2u19LJ5IqwV2VKl0pWVdl4PTsDwC09ClbnJj1y0A3AM9gEh4B9mEo4BvT0XVEncux4YAQ==.eyJlbmRwb2ludF9zbHVnIjoiYW50aHJvcGljL2NsYXVkZS00LjYtc29ubmV0LTIwMjYwMjE3fGNsYXVkZS1vbi1hd3MifQ\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1790698739-Acm5E9n2uVTO14sqrick\",\"object\":\"chat.completion.chunk\",\"created\":1790698739,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Claude Platform on AWS\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":73,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
+19 -3
View File
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
fixedResponse(
sseEvents(
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
chunk({ content: [{ type: "text", text: "Answer" }] }),
chunk({}, "stop"),
),
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
expect(response.reasoning).toBe("Consider")
expect(response.text).toBe("Answer")
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
["Con", undefined],
["sider", undefined],
])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
mistral: {
thinking: {
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
},
},
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
{ type: "text", text: "Answer" },
],
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
),
),
)
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -89,7 +89,14 @@ for (const item of cases) {
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
expect(replay.body.messages).toMatchObject([
{ role: "assistant", content: response.text, reasoning: response.reasoning },
{
role: "assistant",
content:
item.model.route.id === "openrouter"
? [{ type: "text", text: response.text, cache_control: { type: "ephemeral" } }]
: response.text,
reasoning: response.reasoning,
},
])
const replayDetails =
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
@@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
),
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } })
}),
)
it.effect("retains the token-sharing response.failed event for retry hooks", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.failed",
response: {
id: "resp_usage_limit",
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
},
}),
),
),
Effect.flip,
)
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded")
}),
)
it.effect("surfaces response.failed code when no nested message is present", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
+164 -4
View File
@@ -32,6 +32,125 @@ describe("OpenRouter", () => {
}),
)
it.effect("places default cache breakpoints on tools, system boundaries, and the conversation tail", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Model details" },
{ type: "text", text: "Project instructions" },
],
tools: [
{ name: "read", description: "Read", inputSchema: { type: "object", properties: {} } },
{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } },
],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.map((tool) => tool.cache_control)).toEqual([undefined, { type: "ephemeral" }])
expect(prepared.body.messages).toMatchObject([
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral" } },
{ text: "Model details" },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
expect(prepared.body.messages[0]?.content).not.toContainEqual(
expect.objectContaining({ text: "Model details", cache_control: expect.anything() }),
)
}),
)
it.effect("places default cache breakpoints on OpenRouter latest-model aliases", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("~anthropic/claude-sonnet-latest"),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(prepared.body.messages).toMatchObject([
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
it.effect("skips the tool breakpoint for Qwen, which caches tools with the system prompt", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toBeUndefined()
expect(prepared.body.messages).toMatchObject([
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
])
}),
)
it.effect("places the default Qwen conversation-tail breakpoint inside tool-result text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3-coder-plus"),
messages: [
Message.user("Call the tool"),
Message.assistant([{ type: "tool-call", id: "call_1", name: "lookup", input: {} }]),
Message.tool({ id: "call_1", name: "lookup", result: "Done" }),
],
}),
)
expect(prepared.body.messages.at(-1)).toMatchObject({
role: "tool",
tool_call_id: "call_1",
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral" } }],
})
}),
)
it.effect("sends no default breakpoints to upstreams that cache without them", () =>
Effect.gen(function* () {
const openrouter = OpenRouter.configure({ apiKey: "test-key" })
const bodies = yield* Effect.forEach(["google/gemini-2.5-flash", "openai/gpt-5-mini"], (id) =>
compileRequest(
LLM.request({
model: openrouter.model(id),
system: "Base agent",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
}),
).pipe(Effect.map((prepared) => prepared.body)),
)
bodies.forEach((body) => {
expect(body.tools?.[0]?.cache_control).toBeUndefined()
expect(body.messages).toMatchObject([
{ role: "system", content: "Base agent" },
{ role: "user", content: "Hello" },
])
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -74,6 +193,8 @@ describe("OpenRouter", () => {
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant("Unmarked reply"),
Message.user("Call again"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
@@ -90,8 +211,17 @@ describe("OpenRouter", () => {
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
{ role: "assistant", content: "Unmarked reply" },
{ role: "user", content: "Call again" },
{
role: "assistant",
content: [{ type: "text", text: "Calling", cache_control: { type: "ephemeral" } }],
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
},
{
role: "tool",
content: [{ type: "text", text: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } }],
},
])
}),
)
@@ -118,7 +248,7 @@ describe("OpenRouter", () => {
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
it.effect("does not emit text cache markers on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -130,8 +260,38 @@ describe("OpenRouter", () => {
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
{ role: "assistant", content: "" },
])
expect(prepared.body.messages[1]).not.toHaveProperty("cache_control")
}),
)
it.effect("counts wrapped system-update markers once so all four default breakpoints survive", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent" },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
messages: [Message.user("Start"), Message.system("Updated instructions")],
}),
)
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(prepared.body.messages.at(-1)).toMatchObject({
role: "user",
content: [
{ type: "text", text: "Start" },
{
type: "text",
text: "<system-update>\nUpdated instructions\n</system-update>",
cache_control: { type: "ephemeral" },
},
],
})
}),
)
@@ -186,11 +186,17 @@ for (const scenario of scenarios) {
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
// Grouped file headers stack below the stuck Used header.
const group = element
.closest('[data-component="collapsed-tool-group"]')
?.querySelector(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const sessionTop = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
return {
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
groupGap: group ? Math.abs(group.getBoundingClientRect().top - sessionTop) : 0,
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
clickable: trigger.contains(
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
@@ -198,7 +204,7 @@ for (const scenario of scenarios) {
}
}),
)
.toEqual({ gap: 0, titleGap: 0, clickable: true })
.toEqual({ gap: 0, groupGap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.19",
"version": "2.0.20",
"description": "",
"type": "module",
"exports": {
@@ -0,0 +1,23 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { onMount } from "solid-js"
import { DialogChatGPTUsageLimit } from "./chatgpt-usage-limit"
function UsageLimitStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogChatGPTUsageLimit />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open ChatGPT usage limit
</Button>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-chatgpt-usage-limit",
}
export const ChatGPTUsageLimit = { render: () => <UsageLimitStory /> }
@@ -0,0 +1,48 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
export function DialogChatGPTUsageLimit() {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
<DialogTitle>
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.title")}</bdi>
</DialogTitle>
</div>
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.description")}</bdi>
</p>
<Button
variant="contrast"
size="large"
class="mt-8 w-full"
autofocus
onClick={() => {
platform.openExternal("https://chatgpt.com/settings/usage")
dialog.close()
}}
>
{language.t("provider.connect.chatgptUsageLimit.manage")}
</Button>
<Button
variant="ghost"
size="large"
class="mt-2 w-full"
onClick={() => dialog.close()}
>
{language.t("provider.connect.chatgptUsageLimit.close")}
</Button>
</div>
</Dialog>
)
}
@@ -0,0 +1,23 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { onMount } from "solid-js"
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
function WelcomeStory() {
const dialog = useDialog()
const open = () => dialog.show(() => <DialogChatGPTPlanWelcome />)
onMount(open)
return (
<Button variant="neutral" onClick={open}>
Open ChatGPT plan welcome
</Button>
)
}
export default {
title: "App/Dialogs/Connect Provider",
id: "app-dialog-chatgpt-welcome",
}
export const ChatGPTPlanWelcome = { render: () => <WelcomeStory /> }
@@ -0,0 +1,37 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
export function DialogChatGPTPlanWelcome() {
const dialog = useDialog()
const language = useLanguage()
return (
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
<DialogTitle>
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.title")}</bdi>
</DialogTitle>
</div>
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.description")}</bdi>
</p>
<ExternalLink
href="https://chatgpt.com/settings/usage"
dir="auto"
class="mt-1 rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("provider.connect.chatgptWelcome.usage")}
</ExternalLink>
<Button variant="contrast" size="large" class="mt-8 w-full" autofocus onClick={() => dialog.close()}>
{language.t("provider.connect.chatgptWelcome.confirm")}
</Button>
</div>
</Dialog>
)
}
+29 -6
View File
@@ -39,6 +39,9 @@ import { OpenCodeLogo } from "@/providers/opencode-logo"
import { decode64 } from "@/runtime/persistence/base64"
import { SettingsList } from "@/settings/list"
import { useTabs } from "@/shell/tabs/tabs"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { Schema } from "effect"
import {
CONSOLE_INTEGRATION,
CONSOLE_PROVIDERS,
@@ -48,6 +51,7 @@ import {
type ProviderConnectMethod,
} from "./controller"
import { ConsoleAuthorization } from "./console"
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
import { authServerName, RemoteAuthNotice } from "./remote"
import "./models.css"
@@ -81,8 +85,15 @@ export const DialogConnectProvider: Component<{
completed: false,
modelProvider: undefined as { id: string; name: string } | undefined,
authorization: false,
chatgptWelcome: false,
})
const language = useLanguage()
const dialog = useDialog()
const [welcome, setWelcome, , welcomeReady] = persisted(
Persist.global("chatgpt-plan-welcome.v1"),
Persistence.struct({ seen: Schema.Boolean }),
{ seen: false },
)
const reset = controller.reset
const back = { current: reset }
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
@@ -112,7 +123,11 @@ export const DialogConnectProvider: Component<{
setBack={(handler) => (back.current = handler)}
selection={props.selection}
onDone={props.onDone ? () => setState("completed", true) : undefined}
onConnected={() => props.onConnected?.(provider)}
onConnected={(methodID) => {
props.onConnected?.(provider)
if (provider === "openai" && methodID === "chatgpt-token-sharing")
setState("chatgptWelcome", true)
}}
onFirstConnection={(provider) => setState("modelProvider", provider)}
onAuthorization={(authorization) => setState("authorization", authorization)}
/>
@@ -136,9 +151,16 @@ export const DialogConnectProvider: Component<{
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
}
onCloseAutoFocus={(event) => {
if (!state.completed || !props.onDone) return
event.preventDefault()
props.onDone()
if (state.completed && props.onDone) {
event.preventDefault()
props.onDone()
}
if (!state.chatgptWelcome) return
void Promise.resolve(welcomeReady.promise).then(() => {
if (welcome.seen) return
setWelcome("seen", true)
void dialog.show(() => <DialogChatGPTPlanWelcome />)
})
}}
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
classList={{
@@ -354,7 +376,7 @@ function ProviderConnection(props: {
setBack: (handler: () => void) => void
selection?: ModelSelection
onDone?: () => void
onConnected?: () => void
onConnected?: (methodID?: string) => void
onFirstConnection: (provider: { id: string; name: string }) => void
onAuthorization: (authorization: boolean) => void
}) {
@@ -401,7 +423,8 @@ function ProviderConnection(props: {
prepare: isConsole ? prepareConsoleCatalog : undefined,
pollInterval: isConsole ? 500 : undefined,
onComplete: () => {
props.onConnected?.()
const method = controller.currentMethod()
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
global.models.show(
@@ -0,0 +1,78 @@
import { Button } from "@opencode/ui/button"
import { createSignal } from "solid-js"
import type { ModelSelection } from "./selection"
import { ModelSelectorPopoverView } from "./select-dialog"
const chatgpt = {
id: "gpt-5.6-sol",
providerID: "openai",
api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" },
name: "GPT-5.6 Sol",
family: "gpt",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: true,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 400_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-09-01",
variants: {},
provider: {
id: "openai",
name: "OpenAI",
source: "custom",
env: [],
options: {},
models: {},
},
latest: true,
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
const models = [
chatgpt,
{
...chatgpt,
id: "gpt-5.6-terra",
api: { ...chatgpt.api, id: "gpt-5.6-terra" },
name: "GPT-5.6 Terra",
latest: false,
},
]
function SelectorStory(props: { plan: boolean }) {
const [current, setCurrent] = createSignal(models[0].id)
return (
<div class="flex min-h-[280px] items-end justify-center">
<ModelSelectorPopoverView
trigger={(trigger) => (
<Button {...trigger} variant="ghost-muted">
{models.find((model) => model.id === current())?.name}
</Button>
)}
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
groups={(items) => [{ category: "openai", items }]}
current={`openai:${current()}`}
chatgptPlan={props.plan}
select={(item) => setCurrent(item.id)}
onManage={() => undefined}
onClose={() => undefined}
/>
</div>
)
}
export default {
title: "App/Dialogs/Model Selector",
id: "app-dialog-model-selector",
}
export const ChatGPTPlan = { render: () => <SelectorStory plan /> }
export const ApiKey = { render: () => <SelectorStory plan={false} /> }
@@ -15,13 +15,16 @@ import { Menu } from "@opencode/ui/menu"
import { TextInput } from "@opencode/ui/text-input"
import { ModelTooltip } from "./tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { decode64 } from "@/runtime/persistence/base64"
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./search"
import { SettingsList } from "@/settings/list"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
@@ -218,11 +221,21 @@ export function ModelSelectorPopover(props: {
onClose?: () => void
}) {
const dialog = useDialog()
const data = useData()
const location = useWorkspaceLocation()
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
const chatgptPlan = () => {
if (!controller.current()?.startsWith("openai:")) return false
const connection = data.location.integration
.list(location().ref)
?.find((integration) => integration.id === "openai")
?.connections[0]
return connection?.type === "credential" && connection.method === "oauth"
}
return (
<ModelSelectorPopoverView
@@ -230,6 +243,7 @@ export function ModelSelectorPopover(props: {
models={controller.models}
groups={controller.groups}
current={controller.current()}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
@@ -281,11 +295,12 @@ function createModelSelectorController(input: {
}
}
function ModelSelectorPopoverView(props: {
export function ModelSelectorPopoverView(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: string | undefined
chatgptPlan?: boolean
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
@@ -368,7 +383,8 @@ function ModelSelectorPopoverView(props: {
<Menu.Portal>
<Menu.Content
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
classList={{ "!w-[320px]": props.chatgptPlan }}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
@@ -505,6 +521,20 @@ function ModelSelectorPopoverView(props: {
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</Menu.Item>
</div>
<Show when={props.chatgptPlan}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex min-h-10 items-center gap-2 px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
<ExternalLink
href="https://chatgpt.com/settings/usage"
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
>
{language.t("dialog.model.chatgptManageUsage")}
<Icon name="arrow-up-right" size="small" />
</ExternalLink>
</div>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
+10
View File
@@ -186,6 +186,8 @@ export const dict = {
"dialog.model.empty": "No model results",
"dialog.model.manage": "Manage models",
"dialog.model.manage.description": "Customize which models appear in the model selector.",
"dialog.model.chatgptPlan": "Using ChatGPT plan",
"dialog.model.chatgptManageUsage": "Manage usage",
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
@@ -217,7 +219,15 @@ export const dict = {
"provider.connect.models.title": "Connected to {{provider}}",
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
"provider.connect.models.available": "Available models",
"provider.connect.chatgptWelcome.title": "ChatGPT connected",
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode can use your ChatGPT plan.",
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
"provider.connect.chatgptWelcome.confirm": "Got it",
"provider.connect.models.list": "Models available from {{provider}}",
"provider.connect.chatgptUsageLimit.title": "ChatGPT usage limit reached",
"provider.connect.chatgptUsageLimit.description": "Check your usage and limits in ChatGPT settings.",
"provider.connect.chatgptUsageLimit.manage": "Manage usage",
"provider.connect.chatgptUsageLimit.close": "Close",
"provider.connect.console.refreshFailed":
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
"provider.connect.console.connected": "OpenCode connected",
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState } from "./usage-exceeded-dialogs"
import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"
const decode = Schema.decodeUnknownSync(
@@ -38,3 +38,31 @@ describe("usage exceeded preferences", () => {
})
})
})
describe("ChatGPT usage limit", () => {
const message =
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
test("detects the mapped message regardless of failure type", () => {
expect(
isChatGPTUsageLimit({
type: "provider.rate-limit",
message,
status: 429,
}),
).toBe(true)
expect(isChatGPTUsageLimit({ type: "provider.unknown", message })).toBe(true)
})
test("ignores other rate limits and similar messages", () => {
expect(isChatGPTUsageLimit({ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 })).toBe(
false,
)
expect(
isChatGPTUsageLimit({
type: "provider.rate-limit",
message: "ChatGPT usage limit reached",
}),
).toBe(false)
})
})
@@ -1,6 +1,6 @@
import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode/client/promise"
import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
@@ -14,6 +14,14 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) {
return (
error.message ===
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
)
}
export const GoUpsellState = Persistence.struct({
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
@@ -52,6 +60,25 @@ export function useUsageExceededDialogs() {
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})
const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted(
Persist.global("chatgpt-usage-limit"),
Persistence.struct({ lastSeenAt: Schema.NullOr(Schema.Finite) }),
{ lastSeenAt: null },
)
onCleanup(
sdk().event.on("session.step.failed", (evt) => {
if (evt.data.sessionID !== params.id) return
if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return
if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return
void import("@/providers/connect/chatgpt-usage-limit").then((usage) => {
if (dialog.active) return
setChatGPTUsageLimit("lastSeenAt", Date.now())
dialog.show(() => <usage.DialogChatGPTUsageLimit />)
})
}),
)
onCleanup(
sdk().event.on("session.status", (evt) => {
@@ -0,0 +1,94 @@
import { afterAll, beforeAll, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode/client/solid"
import type { ServerConnection } from "@/runtime/server/registry"
import { ServerScope } from "@/runtime/server/scope"
import type { Tab } from "@/shell/tabs/tabs"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
const alerts: string[] = []
const tabs: { store: Tab[] } = { store: [] }
let createServerNotificationState: typeof import("./notification").createServerNotificationState
let storage: typeof import("@/runtime/persistence/storage")
beforeAll(async () => {
storage = await import("@/runtime/persistence/storage")
const { sessionIDHasOpenTab } = await import("@/shell/tabs/tabs")
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
notify: async (title: string) => {
alerts.push(title)
},
}),
}))
mock.module("@/settings/model", () => ({
useSettings: () => ({
sounds: { agentEnabled: () => false, errorsEnabled: () => false },
notifications: { agent: () => true, errors: () => true },
}),
}))
mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
mock.module("@/shell/tabs/tabs", () => ({
useTabs: () => tabs,
sessionIDHasOpenTab,
}))
mock.module("@/runtime/persistence/storage", () => ({
...storage,
persisted: () => {
const [store, setStore] = createStore({ list: [] })
return [store, setStore, undefined, () => false]
},
}))
createServerNotificationState = (await import("./notification")).createServerNotificationState
})
afterAll(() => mock.module("@/runtime/persistence/storage", () => storage))
test.each([
["session.execution.succeeded", "notification.session.responseReady.title"],
["session.execution.failed", "notification.session.error.title"],
] as const)("system alert for %s requires an open session tab", async (type, title) => {
alerts.length = 0
tabs.store = [{ type: "session", server, sessionId: "another-session" }]
let listener: ((event: unknown) => void) | undefined
const dispose = createRoot((dispose) => {
const state = createServerNotificationState({
key: server,
sdk: {
scope: ServerScope.local,
event: {
listen: (fn: typeof listener) => {
listener = fn
return () => {}
},
},
} as unknown as ServerSDK,
data: { session: { get: () => session } } as unknown as Data,
coordinator: { system: async (_id: string, fn: () => Promise<void>) => fn() },
} as Parameters<typeof createServerNotificationState>[0])
return { dispose, state }
})
listener?.({
type,
id: "event-1",
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(dispose.state.session.all(session.id)).toHaveLength(1)
expect(alerts).toEqual([])
tabs.store = [{ type: "session", server, sessionId: session.id }]
listener?.({
type,
id: "event-2",
data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(alerts).toEqual([title])
dispose.dispose()
})
@@ -228,7 +228,8 @@ export function createServerNotificationState(input: {
if (!session) return
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
if (hasOpenTab && settings.sounds.agentEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
@@ -240,7 +241,7 @@ export function createServerNotificationState(input: {
session: sessionID,
})
if (settings.notifications.agent()) {
if (hasOpenTab && settings.notifications.agent()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
@@ -255,7 +256,8 @@ export function createServerNotificationState(input: {
if (meta.disposed) return
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
const hasOpenTab = sessionIDHasOpenTab(tabs.store, input.key, sessionID)
if (hasOpenTab && settings.sounds.errorsEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
@@ -270,7 +272,7 @@ export function createServerNotificationState(input: {
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
if (hasOpenTab && settings.notifications.errors()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"bin": {
+20
View File
@@ -183,6 +183,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
},
}),
Spec.make("export", {
description: "print stored credentials, including secrets, as JSON",
params: {
...ServerParams,
target: Argument.string("target").pipe(
Argument.withDescription("Integration ID or name (exports every integration when omitted)"),
Argument.optional,
),
},
}),
Spec.make("import", {
description: "import credentials exported by auth export",
params: {
...ServerParams,
file: Argument.string("file").pipe(
Argument.withDescription("JSON file to import (reads stdin when omitted)"),
Argument.optional,
),
},
}),
Spec.make("switch", {
description: "switch the active account for an integration",
params: {
@@ -0,0 +1,31 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations, request, resolveIntegration } from "./shared"
import { errorMessage } from "../../../util/error"
export default Runtime.handler(
Commands.commands.auth.commands.export,
Effect.fn("cli.auth.export")(
function* (input) {
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const target = Option.getOrUndefined(input.target)
const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined
const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter(
(credential) => !integrationID || credential.integrationID === integrationID,
)
if (process.stdout.isTTY)
process.stderr.write(
"Warning: the output contains secrets; redirect it to a file or pipe it to auth import" + EOL,
)
process.stdout.write(JSON.stringify(credentials, null, 2) + EOL)
},
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -0,0 +1,58 @@
import { EOL } from "node:os"
import { isConflictError, type CredentialCreateInput } from "@opencode/client"
import { Credential } from "@opencode/schema/credential"
import { Effect, Option, Schema } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, request } from "./shared"
import { errorMessage } from "../../../util/error"
import { readStdin } from "../../../util/io"
export default Runtime.handler(
Commands.commands.auth.commands.import,
Effect.fn("cli.auth.import")(
function* (input) {
const file = Option.getOrUndefined(input.file)
if (!file && process.stdin.isTTY)
return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import"))
const text = yield* request(() => (file ? Bun.file(file).text() : readStdin()))
const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text)
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
const existing = yield* request((signal) => client.credential.list({ signal }))
const ids = new Set(existing.map((credential) => credential.id))
const integrations = new Set(existing.map((credential) => credential.integrationID))
const results = yield* Effect.forEach(credentials, (credential) => {
if (ids.has(credential.id)) return Effect.succeed(false)
return request((signal) =>
client.credential.create(
{
id: credential.id,
integrationID: credential.integrationID,
label: credential.label,
// Stored metadata is JSON, but the credential schema types it as unknown while the generated client expects JSON.
value: credential.value as CredentialCreateInput["value"],
// Keep the destination's current selections; only integrations new to it adopt the exported selection.
activate: credential.active && !integrations.has(credential.integrationID),
},
{ signal },
),
).pipe(
Effect.as(true),
Effect.catchIf(isConflictError, () => Effect.succeed(false)),
)
})
const imported = results.filter(Boolean).length
process.stderr.write(
`Imported ${imported} ${imported === 1 ? "credential" : "credentials"}` +
(results.length > imported ? `, skipped ${results.length - imported} already present` : "") +
EOL,
)
},
Effect.catch((error) =>
Effect.sync(() => {
process.stderr.write(errorMessage(error) + EOL)
process.exitCode = 1
}),
),
),
)
@@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
@@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { redactConfig } from "./redact"
export default Runtime.handler(
Commands.commands.debug.commands.config,
Effect.fn("cli.debug.config")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
@@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
import { answerForm } from "../auth/form"
@@ -21,7 +21,7 @@ export default Runtime.handler(
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
intro("Authenticate an MCP server")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integrations = yield* loadIntegrations(client)
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
@@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
@@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode/client/effect/service"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { resolveIntegration } from "./resolve"
const location = { directory: process.cwd() }
@@ -12,7 +12,7 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
if ((yield* ServiceConfig.read()).disabled === true)
return yield* Effect.fail(
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
)
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
@@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Npm } from "@opencode/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export interface Item {
readonly runtime: "Server" | "TUI"
@@ -16,7 +16,7 @@ export interface Item {
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
@@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
import { Config } from "../../../config"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
@@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* (input) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const { endpoint } = yield* ServerConnection.resolve()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const config = yield* Config.Service
+2
View File
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
list: () => import("./commands/handlers/auth/list"),
login: () => import("./commands/handlers/auth/login"),
logout: () => import("./commands/handlers/auth/logout"),
export: () => import("./commands/handlers/auth/export"),
import: () => import("./commands/handlers/auth/import"),
switch: () => import("./commands/handlers/auth/switch"),
},
debug: {
+35 -7
View File
@@ -67,6 +67,9 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
// attached client must not cancel input that may belong to another session.
const GLOBAL_FORM_SESSION_ID = "global"
const PERMISSION_REJECTED_FEEDBACK =
"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."
export async function runNonInteractivePrompt(input: Input) {
const controller = new AbortController()
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
@@ -132,10 +135,34 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
// Subagents run in child sessions; their asks and questions belong to this run too. Other
// sessions on a shared server (e.g. the TUI's) must be left alone.
const owned = new Map<string, Promise<boolean>>([[input.sessionID, Promise.resolve(true)]])
const ownsSession = (sessionID: string): Promise<boolean> => {
const known = owned.get(sessionID)
if (known) return known
const result =
sessionID === GLOBAL_FORM_SESSION_ID
? Promise.resolve(false)
: input.client.session
.get({ sessionID })
.then((session) => (session.parentID ? ownsSession(session.parentID) : false))
.catch(() => false)
owned.set(sessionID, result)
return result
}
const replyPermission = async (request: {
id: string
sessionID: string
action: string
resources: ReadonlyArray<string>
}) => {
// Nobody can approve here. Outside V1 compatibility, reject with feedback so the tool fails
// as ordinary model-visible output and the model continues without the action.
const continuing = !input.auto && input.compatibility !== "v1"
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
if (!continuing) permissionRejected = true
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -144,12 +171,13 @@ export async function runNonInteractivePrompt(input: Input) {
}
await input.client.permission
.reply({
sessionID: input.sessionID,
sessionID: request.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
...(continuing ? { message: PERMISSION_REJECTED_FEEDBACK } : {}),
})
.catch(() => {})
if (!input.auto) {
if (!input.auto && !continuing) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
}
}
@@ -179,14 +207,14 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "permission.asked" && submitted && (await ownsSession(event.data.sessionID))) {
await replyPermission(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
((await ownsSession(event.data.form.sessionID)) ||
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
@@ -18,7 +18,7 @@ export type Resolved = {
readonly service?: ReturnType<typeof managedService>
}
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
if (args.server !== undefined && args.standalone)
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
if (args.server !== undefined) {
@@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
)
return { endpoint } satisfies Resolved
}
if (args.standalone) {
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
return { endpoint: yield* Standalone.start() } satisfies Resolved
}
+25 -2
View File
@@ -12,6 +12,7 @@ import { selfCommand } from "../util/process"
// registration file (by channel), which version, and how to spawn opencode.
export const Info = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
@@ -20,7 +21,7 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
const keys = ["hostname", "port", "password", "cors", "env"] as const
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
})
function configKey(key: string): Key {
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
if (
key === "disabled" ||
key === "hostname" ||
key === "port" ||
key === "password" ||
key === "cors" ||
key === "env"
)
return key
throw new Error(`Unknown service config key: ${key}`)
}
@@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
switch (selected) {
case "disabled": {
return String((yield* read()).disabled ?? false)
}
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
if (selected !== "env" && nestedValue !== undefined)
throw new Error(`Usage: opencode service set ${selected} <value>`)
switch (selected) {
case "disabled": {
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
if (value === "true") yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), disabled: value === "true" })
return
}
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
const selected = configKey(key)
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
switch (selected) {
case "disabled": {
const { disabled: _disabled, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
+1 -1
View File
@@ -38,7 +38,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
pid: process.pid,
}),
)
const resolved = await runPromise(ServerConnection.resolve({}))
const resolved = await runPromise(ServerConnection.resolve())
expect(resolved.endpoint.url).toBe(server.url.toString())
expect(resolved.service).toBeDefined()
+19 -1
View File
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
import { Global } from "@opencode/util/global"
import { OPENCODE_VERSION } from "../src/version"
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, FileSystem, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -21,6 +21,24 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
try {
expect(await run(ServiceConfig.get("disabled"))).toBe("false")
await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false")
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("disabled", "true"))
expect(await run(ServiceConfig.read())).toEqual({ disabled: true })
await run(ServiceConfig.unset("disabled"))
expect(await run(ServiceConfig.read())).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"repository": {
+47 -5
View File
@@ -710,7 +710,12 @@ export type SessionLogOutput =
| undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
}
}
| {
@@ -902,7 +907,12 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly finish?: "content-filter" | undefined
readonly rawFinish?: string | undefined
readonly providerState?: SessionMessage.ProviderState | undefined
@@ -1112,7 +1122,12 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly content?:
| readonly [
(
@@ -1157,7 +1172,12 @@ export type SessionLogOutput =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
}
}
| {
@@ -1238,7 +1258,12 @@ export type SessionLogOutput =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
}
readonly inputID?: SessionMessage.ID | undefined
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
@@ -1718,6 +1743,21 @@ export interface McpApi<E = never> {
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
export type CredentialListOutput = ReadonlyArray<Credential.Entry>
export type CredentialListOperation<E = never> = () => Effect.Effect<CredentialListOutput, E>
export type CredentialCreateInput = {
readonly id?: Credential.ID | undefined
readonly integrationID: Integration.ID
readonly label?: string | undefined
readonly value: Credential.Value
readonly activate?: boolean | undefined
}
export type CredentialCreateOutput = Credential.Entry
export type CredentialCreateOperation<E = never> = (
input: CredentialCreateInput,
) => Effect.Effect<CredentialCreateOutput, E>
export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string }
export type CredentialUpdateOutput = void
export type CredentialUpdateOperation<E = never> = (
@@ -1737,6 +1777,8 @@ export type CredentialRemoveOperation<E = never> = (
) => Effect.Effect<CredentialRemoveOutput, E>
export interface CredentialApi<E = never> {
readonly list: CredentialListOperation<E>
readonly create: CredentialCreateOperation<E>
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
@@ -153,6 +153,9 @@ import type {
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialListOutput,
CredentialCreateInput,
CredentialCreateOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
@@ -1021,6 +1024,30 @@ const adaptGroupMcp = (raw: RawClient["server.mcp"]) => ({
resource: { catalog: EndpointMcpResourceCatalog(raw) },
})
const EndpointCredentialList = (raw: RawClient["server.credential"]) => () =>
preserveEffect<CredentialListOutput>()(
raw["credential.list"]({}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointCredentialCreate = (raw: RawClient["server.credential"]) => (input: CredentialCreateInput) =>
preserveEffect<CredentialCreateOutput>()(
raw["credential.create"]({
payload: {
id: input["id"],
integrationID: input["integrationID"],
label: input["label"],
value: input["value"],
activate: input["activate"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input: CredentialUpdateInput) =>
preserveEffect<CredentialUpdateOutput>()(
raw["credential.update"]({
@@ -1042,6 +1069,8 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
)
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
list: EndpointCredentialList(raw),
create: EndpointCredentialCreate(raw),
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
@@ -147,6 +147,9 @@ import type {
McpDisconnectOutput,
McpResourceCatalogInput,
McpResourceCatalogOutput,
CredentialListOutput,
CredentialCreateInput,
CredentialCreateOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
@@ -1392,6 +1395,29 @@ export function make(options: ClientOptions) {
},
},
credential: {
list: (requestOptions?: RequestOptions) =>
request<{ readonly data: CredentialListOutput }>(
{ method: "GET", path: `/api/credential`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
).then((value) => value.data),
create: (input: CredentialCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: CredentialCreateOutput }>(
{
method: "POST",
path: `/api/credential`,
body: {
id: input["id"],
integrationID: input["integrationID"],
label: input["label"],
value: input["value"],
activate: input["activate"],
},
successStatus: 200,
declaredStatuses: [400, 401, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialUpdateOutput>(
{
+223 -15
View File
@@ -133,7 +133,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = { type: string; message: string; status?: number; response?: { body: string } }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -293,6 +293,15 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type CredentialOAuth = {
type: "oauth"
methodID: string
refresh: string
access: string
expires: number
metadata?: { [x: string]: JsonValue }
}
export type ProjectVcs = string
export type ProjectIcon = { url?: string; override?: string; color?: string }
@@ -1656,7 +1665,7 @@ export type FormMultiselectField1 = {
default?: Array<string>
}
export type FormAnswer1 = { [x: string]: FormValue1 }
export type FormAnswer2 = { [x: string]: FormValue1 }
export type SessionStatusUpdated = {
id: string
@@ -1890,6 +1899,13 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type CredentialKey = {
type: "key"
key: string
metadata?: { [x: string]: JsonValue }
configuration?: FormAnswer
}
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type FormField1 =
@@ -1906,7 +1922,7 @@ export type FormReplied = {
metadata?: { [x: string]: any }
type: "form.replied"
location?: LocationRef
data: { id: string; sessionID: string; answer: FormAnswer1 }
data: { id: string; sessionID: string; answer: FormAnswer2 }
}
export type ReferenceInfo = {
@@ -2225,6 +2241,8 @@ export type SessionMessageAssistantTool1 = {
export type FormFields = [FormField, ...Array<FormField>]
export type CredentialValue = CredentialOAuth | CredentialKey
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
@@ -2279,6 +2297,14 @@ export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string;
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
export type CredentialEntry = {
id: string
integrationID: string
label: string
active: boolean
value: CredentialValue
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
export type SessionMessageInfo =
@@ -3183,7 +3209,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3220,11 +3251,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3276,7 +3317,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3500,7 +3546,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3537,11 +3588,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3593,7 +3654,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3817,7 +3883,12 @@ export type SessionImportInput = {
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly content?: readonly [
(
| { readonly type: "text"; readonly text: string }
@@ -3854,11 +3925,21 @@ export type SessionImportInput = {
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
readonly error?: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
}
}
| (
@@ -3910,7 +3991,12 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly status: "failed"
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
readonly error: {
readonly type: string
readonly message: string
readonly status?: number
readonly response?: { readonly body: string }
}
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -5690,6 +5776,128 @@ export type McpResourceCatalogInput = {
export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpResourceCatalog }
export type CredentialListOutput = { data: Array<CredentialEntry> }["data"]
export type CredentialCreateInput = {
readonly id?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["id"]
readonly integrationID: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["integrationID"]
readonly label?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["label"]
readonly value: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["value"]
readonly activate?: {
readonly id?: string
readonly integrationID: string
readonly label?: string
readonly value:
| {
readonly type: "oauth"
readonly methodID: string
readonly refresh: string
readonly access: string
readonly expires: number
readonly metadata?: { readonly [x: string]: JsonValue }
}
| {
readonly type: "key"
readonly key: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly configuration?: {
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
}
}
readonly activate?: boolean
}["activate"]
}
export type CredentialCreateOutput = { data: CredentialEntry }["data"]
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly label: { readonly label: string }["label"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.19",
"version": "2.0.20",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.19",
"version": "2.0.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.19",
"version": "2.0.20",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -129,6 +129,7 @@
"https-proxy-agent": "7.0.6",
"ignore": "7.0.5",
"immer": "11.1.4",
"jose": "6.0.11",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
+32 -13
View File
@@ -38,11 +38,16 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Creates a credential for an integration and returns the new record. */
/**
* Creates a credential for an integration and returns the new record. The new credential becomes the
* integration's selection unless `activate` is false and the integration already has a credential.
*/
readonly create: (input: {
readonly id?: ID
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
readonly activate?: boolean
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
@@ -99,19 +104,30 @@ const layer = Layer.effect(
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({
id: ID.create(),
id: input.id ?? ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",
value: input.value,
})
yield* db
const activated = yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(CredentialTable)
.set({ active: false })
const current = yield* tx
.select({ id: CredentialTable.id, active: CredentialTable.active })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
const activate = input.activate !== false || !current
if (activate)
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
// Legacy rows have no active flag and the newest one is selected, so pin it before inserting a newer row.
if (!activate && current && !current.active)
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, current.id)).run()
yield* tx
.insert(CredentialTable)
.values({
@@ -119,9 +135,10 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
active: activate,
})
.run()
return activate
}),
)
.pipe(
@@ -138,13 +155,15 @@ const layer = Layer.effect(
credentialID: credential.id,
integrationID: credential.integrationID,
type: credential.value.type,
active: activated,
})
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
if (activated)
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
+23 -3
View File
@@ -5,6 +5,8 @@ import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#s
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode/util/global"
import { closeSync, existsSync, openSync } from "node:fs"
import { chmod } from "node:fs/promises"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -70,13 +72,31 @@ export function layer(options: Options = { path: ":memory:" }) {
Layer.provide(sqliteLayer({ filename })),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
if (filename === ":memory:") return provide(filename)
const file = isAbsolute(filename) ? filename : join((yield* Global.Service).data, filename)
yield* Effect.promise(() => restrictToOwner(file))
return provide(file)
}),
)
}
// SQLite creates new sidecars with the database's mode, but does not tighten existing sidecars.
// Windows relies on the user profile directory's inherited ACLs instead of POSIX modes.
async function restrictToOwner(filename: string) {
if (process.platform === "win32") return
// Opening and closing an existing database can release another connection's POSIX locks.
// Create missing files synchronously so another fiber cannot open one before it is restricted.
if (!existsSync(filename)) closeSync(openSync(filename, "a", 0o600))
await chmod(filename, 0o600)
await Promise.all(
[`${filename}-wal`, `${filename}-shm`].map((file) =>
chmod(file, 0o600).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error
}),
),
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
@@ -149,7 +149,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
responseFormat,
}: LanguageModelV3CallOptions) {
const warnings: SharedV3Warning[] = []
const modelConfig = getResponsesModelConfig(this.modelId)
if (topK != null) {
warnings.push({ type: "unsupported", feature: "topK" })
@@ -186,7 +185,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
const { input, warnings: inputWarnings } = await convertToOpenAIResponsesInput({
prompt,
systemMessageMode: modelConfig.systemMessageMode,
systemMessageMode: "system",
fileIdPrefixes: this.config.fileIdPrefixes,
store,
})
@@ -286,84 +285,18 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
safety_identifier: openaiOptions?.safetyIdentifier,
top_logprobs: topLogprobs,
// model-specific settings:
...(modelConfig.isReasoningModel &&
(openaiOptions?.reasoningEffort != null || openaiOptions?.reasoningSummary != null) && {
reasoning: {
...(openaiOptions?.reasoningEffort != null && {
effort: openaiOptions.reasoningEffort,
}),
...(openaiOptions?.reasoningSummary != null && {
summary: openaiOptions.reasoningSummary,
}),
},
}),
...(modelConfig.requiredAutoTruncation && {
truncation: "auto",
...((openaiOptions?.reasoningEffort != null || openaiOptions?.reasoningSummary != null) && {
reasoning: {
...(openaiOptions?.reasoningEffort != null && {
effort: openaiOptions.reasoningEffort,
}),
...(openaiOptions?.reasoningSummary != null && {
summary: openaiOptions.reasoningSummary,
}),
},
}),
}
if (modelConfig.isReasoningModel) {
// remove unsupported settings for reasoning models
// see https://platform.openai.com/docs/guides/reasoning#limitations
if (baseArgs.temperature != null) {
baseArgs.temperature = undefined
warnings.push({
type: "unsupported",
feature: "temperature",
details: "temperature is not supported for reasoning models",
})
}
if (baseArgs.top_p != null) {
baseArgs.top_p = undefined
warnings.push({
type: "unsupported",
feature: "topP",
details: "topP is not supported for reasoning models",
})
}
} else {
if (openaiOptions?.reasoningEffort != null) {
warnings.push({
type: "unsupported",
feature: "reasoningEffort",
details: "reasoningEffort is not supported for non-reasoning models",
})
}
if (openaiOptions?.reasoningSummary != null) {
warnings.push({
type: "unsupported",
feature: "reasoningSummary",
details: "reasoningSummary is not supported for non-reasoning models",
})
}
}
// Validate flex processing support
if (openaiOptions?.serviceTier === "flex" && !modelConfig.supportsFlexProcessing) {
warnings.push({
type: "unsupported",
feature: "serviceTier",
details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
})
// Remove from args if not supported
baseArgs.service_tier = undefined
}
// Validate priority processing support
if (openaiOptions?.serviceTier === "priority" && !modelConfig.supportsPriorityProcessing) {
warnings.push({
type: "unsupported",
feature: "serviceTier",
details:
"priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported",
})
// Remove from args if not supported
baseArgs.service_tier = undefined
}
return {
getHostedToolName,
args: {
@@ -1658,69 +1591,6 @@ function isErrorChunk(chunk: z.infer<typeof openaiResponsesChunkSchema>): chunk
return chunk.type === "error"
}
type ResponsesModelConfig = {
isReasoningModel: boolean
systemMessageMode: "remove" | "system" | "developer"
requiredAutoTruncation: boolean
supportsFlexProcessing: boolean
supportsPriorityProcessing: boolean
}
function getResponsesModelConfig(modelId: string): ResponsesModelConfig {
const supportsFlexProcessing =
modelId.startsWith("o3") ||
modelId.startsWith("o4-mini") ||
(modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
const supportsPriorityProcessing =
modelId.startsWith("gpt-4") ||
modelId.startsWith("gpt-5-mini") ||
(modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-nano") && !modelId.startsWith("gpt-5-chat")) ||
modelId.startsWith("o3") ||
modelId.startsWith("o4-mini")
const defaults = {
requiredAutoTruncation: false,
systemMessageMode: "system" as const,
supportsFlexProcessing,
supportsPriorityProcessing,
}
// gpt-5-chat models are non-reasoning
if (modelId.startsWith("gpt-5-chat")) {
return {
...defaults,
isReasoningModel: false,
}
}
// o series reasoning models:
if (
modelId.startsWith("o") ||
modelId.startsWith("gpt-5") ||
modelId.startsWith("codex-") ||
modelId.startsWith("computer-use")
) {
if (modelId.startsWith("o1-mini") || modelId.startsWith("o1-preview")) {
return {
...defaults,
isReasoningModel: true,
systemMessageMode: "remove",
}
}
return {
...defaults,
isReasoningModel: true,
systemMessageMode: "developer",
}
}
// gpt models:
return {
...defaults,
isReasoningModel: false,
}
}
// TODO AI SDK 6: use optional here instead of nullish
const openaiResponsesProviderOptionsSchema = z.object({
include: z
-1
View File
@@ -9,7 +9,6 @@ const prefixes = {
question: "que",
part: "prt",
pty: "pty",
schedule: "sch",
tool: "tool",
workspace: "wrk",
} as const
+5 -1
View File
@@ -286,7 +286,11 @@ const layer = Layer.effect(
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new DeclinedError())
// Feedback applies to the whole batch, so parallel asks don't end the step.
yield* Deferred.fail(
item.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
)
pending.delete(id)
}
return
-5
View File
@@ -61,7 +61,6 @@ import { Ripgrep } from "../ripgrep.js"
import { Session } from "../session.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { SessionSchedule } from "../session/schedule.js"
import { Shell } from "../shell.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
@@ -103,7 +102,6 @@ import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { VerbosityPlugin } from "./verbosity.js"
import { WarmingPlugin } from "./warming.js"
import { SchedulePlugin } from "./schedule.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = [
@@ -144,7 +142,6 @@ const services = [
Session.Service,
SessionCompaction.Service,
SessionInstructions.Service,
SessionSchedule.Service,
Shell.Service,
ShellSelect.Service,
Snapshot.Service,
@@ -198,7 +195,6 @@ export const requirements = LayerNode.group([
Session.node,
SessionCompaction.node,
SessionInstructions.node,
SessionSchedule.node,
Shell.node,
ShellSelect.node,
Snapshot.node,
@@ -249,7 +245,6 @@ const pre = [
WebSearchTool.Plugin,
WriteTool.Plugin,
WarmingPlugin.Plugin,
SchedulePlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [
+2
View File
@@ -1,6 +1,7 @@
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js"
import { AzurePlugin } from "./provider/azure.js"
import { CerebrasPlugin } from "./provider/cerebras.js"
import { ChatGPTPlugin } from "./provider/chatgpt.js"
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js"
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js"
import { CoherePlugin } from "./provider/cohere.js"
@@ -35,6 +36,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
AmazonBedrockPlugin,
AzurePlugin,
CerebrasPlugin,
ChatGPTPlugin,
CloudflareAIGatewayPlugin,
CloudflareWorkersAIPlugin,
CoherePlugin,
@@ -0,0 +1,516 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { Context } from "@opencode/plugin/effect/plugin"
import { define } from "@opencode/plugin/effect/plugin"
import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import type { Server, ServerResponse } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
// First-time sign-in registers a user-owned client; OpenAI returns its issued client ID on the callback.
const registrationClientID = "dynamic_agent_client"
const agentName = "OpenCode"
const issuer = "https://auth.openai.com"
const tokenURL = `${issuer}/api/accounts/oauth/token`
const resource = "https://api.openai.com/v1"
const tokenSharingScope = "chatgpt.tokens.use.direct"
const providerID = Provider.ID.openai
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt-token-sharing")
const modelCacheKey = (clientID: string) => `models:${clientID}`
const fallbackModels = new Set([
"gpt-5.5",
"gpt-5.5-fast",
"gpt-5.6-luna",
"gpt-5.6-luna-fast",
"gpt-5.6-sol",
"gpt-5.6-sol-fast",
"gpt-5.6-terra",
"gpt-5.6-terra-fast",
"gpt-6-astra",
"gpt-6-astra-fast",
])
const nonRetryableSharingCodes = [
"subscription_sharing_usage_limit_exceeded",
"subscription_sharing_user_not_eligible",
"subscription_sharing_unsupported_capability",
"subscription_sharing_route_not_supported",
"subscription_sharing_invalid_user",
"chatpass_v2_scope_not_authorized",
"chatpass_v2_invalid_authorization_context",
]
type Pkce = {
verifier: string
challenge: string
}
type TokenResponse = {
access_token: string
refresh_token: string
id_token?: string
expires_in?: number
scope?: string
}
const RemoteModel = Schema.Struct({
slug: Schema.String,
display_name: Schema.String,
visibility: Schema.String,
supported_in_api: Schema.Boolean,
context_window: Schema.Int.check(Schema.isGreaterThan(1)),
input_modalities: Schema.Array(Schema.String),
supported_reasoning_levels: Schema.optional(Schema.Array(Schema.Struct({ effort: Schema.String }))),
})
type RemoteModel = typeof RemoteModel.Type
const RemoteModels = Schema.Struct({ models: Schema.Array(RemoteModel) })
const decodeCachedModels = Schema.decodeUnknownOption(Schema.Array(RemoteModel))
// Credential metadata is merged into provider settings; these keys are not OpenAI request options.
const decodeMetadata = Schema.decodeUnknownOption(
Schema.Struct({
clientID: Schema.String,
scopes: Schema.optional(Schema.Array(Schema.String)),
models: Schema.optional(Schema.Array(RemoteModel)),
}),
)
const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) =>
({
integrationID,
method: {
id: methodID,
type: "oauth",
label: "Sign in with ChatGPT",
},
authorize: () =>
Effect.gen(function* () {
const storedHostID = yield* storage.get("chatgpt-agent-host-id")
const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}`
if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID)
const pkce = yield* Effect.promise(generatePKCE)
const state = randomValue()
const nonce = randomValue()
const savedID = savedClientID()
const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>()
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const authorizationCode = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(received, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
return
}
if (!authorizationCode || url.searchParams.get("state") !== state) {
const message = authorizationCode ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(received, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
return
}
if (
!Effect.runSync(
Deferred.succeed(received, {
code: authorizationCode,
clientID: url.searchParams.get("client_id") ?? undefined,
response,
}),
)
)
response.writeHead(409).end("OAuth callback already received")
})
const port = yield* listen(server)
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://127.0.0.1:${port}/auth/callback`
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state, nonce, savedID, hostID),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Effect.gen(function* () {
const result = yield* Deferred.await(received)
const respond = (error?: string) =>
Effect.sync(() =>
result.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "ChatGPT" })
: OauthCallbackPage.success({ provider: "ChatGPT" }),
),
)
return yield* Effect.gen(function* () {
// Reauthorization callbacks may omit the client ID; reuse the one this attempt started with.
if (savedID && result.clientID && result.clientID !== savedID)
return yield* Effect.fail(
new Error("ChatGPT returned a different client than this connection. Connect again."),
)
const clientID = savedID ?? result.clientID
if (!clientID)
return yield* Effect.fail(new Error("ChatGPT sign-in did not return a client ID. Connect again."))
const tokens = yield* exchange(result.code, clientID, redirect, pkce, app)
if (!tokens.scope?.split(" ").includes(tokenSharingScope))
return yield* Effect.fail(
new Error(
"ChatGPT sign-in finished without token sharing. Sign in again and allow token sharing, or connect OpenAI with an API key.",
),
)
if (!tokens.id_token) return yield* Effect.fail(new Error("ChatGPT sign-in did not return an ID token."))
yield* verifyIDToken(tokens.id_token, clientID, nonce)
return credential(tokens, clientID)
}).pipe(
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")),
Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())),
)
}),
}
}),
refresh: (value) => refresh(value, app),
}) satisfies IntegrationOAuthMethodRegistration
function listen(server: Server) {
return Effect.callback<number, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(0, "127.0.0.1", () => {
server.off("error", onError)
const address = server.address()
if (!address || typeof address === "string") return resume(Effect.fail(new Error("Missing OAuth callback port")))
resume(Effect.succeed(address.port))
})
})
}
export const ChatGPTPlugin = define({
id: "opencode.provider.chatgpt",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt: Credential.OAuth | undefined
let available: ReadonlyArray<RemoteModel> | undefined
let source: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
const load = Effect.fn("ChatGPTPlugin.load")(function* () {
const previous = IntegrationConnection.key(source)
const connection = yield* ctx.integration.connection.active(integrationID)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
chatgpt = credential?.type === "oauth" && credential.methodID === methodID ? credential : undefined
source = chatgpt ? connection : undefined
if (previous !== IntegrationConnection.key(source)) {
const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))
const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined
available = Option.getOrUndefined(decodeCachedModels(stored))
}
})
yield* ctx.integration.transform((editor) => {
editor.method.update(
signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage),
)
})
yield* Effect.forEach(
yield* credentials.list(Integration.ID.make("openai")),
(entry) => {
const value = entry.value
if (value.type !== "oauth" || value.methodID !== Integration.MethodID.make("chatgpt-browser"))
return Effect.void
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
if (!metadata) return Effect.void
return Effect.gen(function* () {
const saved = yield* ctx.storage.get(modelCacheKey(metadata.clientID))
if (!Option.isSome(decodeCachedModels(saved)) && metadata.models)
yield* ctx.storage.set(modelCacheKey(metadata.clientID), metadata.models)
const { models: _, ...rest } = value.metadata ?? {}
yield* credentials.update(entry.id, {
value: Credential.OAuth.make({ ...value, methodID, metadata: rest }),
})
})
},
{ discard: true },
)
yield* load()
yield* ctx.session.hook(
"retry",
(event) =>
Effect.sync(() => {
if (!chatgpt || !nonRetryableSharingCodes.some((code) => event.error.response?.body.includes(code))) return
event.decision = { retry: false }
}),
{ providerID },
)
yield* ctx.provider.transform((providers) => {
const item = providers.get(providerID)
if (!item) return
if (!chatgpt || !source) return
providers.update(providerID, (provider) => {
provider.settings = Provider.mergeOverlay(provider.settings, {
baseURL: resource,
transport: "http",
compaction: { type: "summary" },
})
})
const updated = providers.get(providerID)
if (!updated) return
providers.add({
info: updated.provider,
models: available
? deriveModels(available, Array.from(item.models.values()))
: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
sourceConnection: source,
})
})
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(model.providerID, model.id, (draft) => {
if (!chatgpt) return
// Token sharing does not support native /responses/compact.
draft.settings = { ...draft.settings, compaction: { type: "summary" } }
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
return
}
if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) {
draft.enabled = false
return
}
draft.cost = []
})
}
})
const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () {
const connection = source
const credential =
connection?.type === "credential"
? (yield* credentials.get(Credential.ID.make(connection.id)))?.value
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000)
return
const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata))
if (!metadata) return
const models = yield* fetchModels(credential.access, ctx.app).pipe(
Effect.timeout(15_000),
Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
)
if (!models) return
yield* loading.withPermit(
Effect.gen(function* () {
if (
IntegrationConnection.key(connection) !== IntegrationConnection.key(source) ||
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID))
)
return
if (JSON.stringify(models) === JSON.stringify(available)) return
yield* ctx.storage.set(modelCacheKey(metadata.clientID), models)
available = models
yield* ctx.provider.reload()
}),
)
})
const reload = () =>
loading
.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
.pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid)))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === integrationID),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
yield* refreshModels().pipe(Effect.forkScoped)
yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
function headers(app: App.Info) {
return { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": App.useragent(app) }
}
function exchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) {
return request<TokenResponse>(tokenURL, {
method: "POST",
headers: headers(app),
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: clientID,
code,
code_verifier: pkce.verifier,
redirect_uri: redirect,
resource,
}).toString(),
})
}
function refresh(value: Credential.OAuth, app: App.Info) {
return Effect.gen(function* () {
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
if (!metadata)
return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again."))
const tokens = yield* request<TokenResponse>(tokenURL, {
method: "POST",
headers: headers(app),
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: metadata.clientID,
refresh_token: value.refresh,
resource,
}).toString(),
})
return credential(tokens, metadata.clientID, metadata.scopes)
})
}
export function fetchModels(token: string, app: App.Info, baseURL = resource) {
return Effect.gen(function* () {
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const response = yield* http.execute(
HttpClientRequest.get(`${baseURL}/models`).pipe(
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeader("User-Agent", App.useragent(app)),
),
)
const data = yield* HttpClientResponse.schemaBodyJson(RemoteModels)(response)
const models = data.models.filter((model) => model.visibility === "list" && model.supported_in_api)
if (!models.length) return yield* Effect.fail(new Error("No ChatGPT models are available for this account."))
return models
})
}
export function deriveModels(remote: ReadonlyArray<RemoteModel>, existing: ReadonlyArray<Model.Info>) {
const byID = new Map(remote.map((model) => [model.slug, model]))
const known = new Set(existing.map((model) => model.id))
return [
...existing.flatMap((model) => {
const found = byID.get(model.modelID)
return found ? [deriveModel(found, model)] : []
}),
...remote
.filter((model) => !known.has(Model.ID.make(model.slug)))
.map((model) => deriveModel(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))),
]
}
function deriveModel(remote: RemoteModel, previous: Model.Info): Model.Info {
return {
...previous,
name: previous.id === previous.modelID ? remote.display_name : previous.name,
package: previous.package ?? "@opencode/ai/providers/openai",
capabilities: {
...previous.capabilities,
input: remote.input_modalities.filter((modality) => modality === "text" || modality === "image"),
},
variants: [
...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"),
...(remote.supported_reasoning_levels ?? []).map(
({ effort }) =>
previous.variants.find(
(variant) => variant.id === effort && variant.settings?.reasoningEffort === effort,
) ?? { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } },
),
],
limit: { context: remote.context_window, output: previous.limit.output },
}
}
export function verifyIDToken(
token: string,
clientID: string,
nonce: string,
jwksURL = new URL(`${issuer}/.well-known/jwks.json`),
) {
return Effect.tryPromise({
try: async () => {
const { createRemoteJWKSet, jwtVerify } = await import("jose")
const { payload } = await jwtVerify(token, createRemoteJWKSet(jwksURL), {
issuer,
audience: clientID,
algorithms: ["RS256"],
requiredClaims: ["exp", "nonce", "sub"],
})
if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("ID token subject is missing")
if (payload.nonce !== nonce) throw new Error("ID token nonce does not match this sign-in attempt")
},
catch: (cause) => new Error("ChatGPT sign-in returned an invalid ID token.", { cause }),
})
}
function request<A>(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json() as Promise<A>
},
catch: (cause) => cause,
})
}
function credential(tokens: TokenResponse, clientID: string, scopes?: ReadonlyArray<string>) {
return Credential.OAuth.make({
type: "oauth",
methodID,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
metadata: { clientID, scopes: tokens.scope?.split(" ").filter(Boolean) ?? scopes ?? [] },
})
}
async function generatePKCE(): Promise<Pkce> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function randomValue() {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
function base64UrlEncode(buffer: ArrayBuffer) {
return Buffer.from(buffer).toString("base64url")
}
function authorizeURL(
redirect: string,
pkce: Pkce,
state: string,
nonce: string,
savedID: string | undefined,
hostID: string,
) {
return `${issuer}/api/accounts/authorize?${new URLSearchParams({
client_id: savedID ?? registrationClientID,
...(savedID ? {} : { agent_name_hint: agentName }),
ext_agent_host_id: hostID,
// Enable only for user-requested consent retries after OpenAI confirms deployment;
// ordinary sign-ins must not force reconsent.
// force_reconsent: "true",
response_type: "code",
redirect_uri: redirect,
scope: `openid profile email offline_access resource.invoke ${tokenSharingScope}`,
resource,
state,
nonce,
code_challenge_method: "S256",
code_challenge: pkce.challenge,
})}`
}
-136
View File
@@ -1,136 +0,0 @@
export * as SchedulePlugin from "./schedule.js"
import { ToolFailure } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Clock, Effect, Schema } from "effect"
import { SessionSchedule } from "../session/schedule.js"
const CreateInput = Schema.Struct({
prompt: Schema.String.check(Schema.isMinLength(1)).annotate({
description:
"Message delivered to this session as a new user message when the schedule fires. Write a complete, self-contained instruction.",
}),
every: Schema.optionalKey(Schema.String).annotate({
description: "Repeat interval such as 30s, 15m, 2h or 1d. Without `at`, the first run is one interval from now.",
}),
at: Schema.optionalKey(Schema.String).annotate({
description:
"ISO 8601 timestamp with a timezone offset, e.g. 2026-09-29T15:00:00-04:00. Alone it schedules a single run; with `every` it sets the first run.",
}),
})
const CancelInput = Schema.Struct({
id: Schema.String.annotate({ description: "Schedule ID returned by schedule_create or schedule_list." }),
})
const View = Schema.Struct({
id: Schema.String,
prompt: Schema.String,
next: Schema.String.annotate({ description: "Next run as an ISO 8601 timestamp." }),
every: Schema.optionalKey(Schema.String).annotate({ description: "Repeat interval; absent for a single run." }),
})
const ListOutput = Schema.Struct({
now: Schema.String.annotate({ description: "Current server time as an ISO 8601 timestamp." }),
schedules: Schema.Array(View),
})
const view = (info: SessionSchedule.Info) => ({
id: info.id,
prompt: info.text,
next: new Date(info.next).toISOString(),
...(info.every === undefined ? {} : { every: `${info.every / 1000}s` }),
})
export const Plugin = define({
id: "opencode.schedule",
effect: Effect.fn(function* (ctx) {
const schedule = yield* SessionSchedule.Service
yield* ctx.command.transform((editor) => {
editor.add({
name: "schedule",
description: "repeat a prompt in this session: <30s|15m|2h> <prompt>, or off",
execute: (input) =>
Effect.gen(function* () {
const text = input.prompt.text.trim()
if (text === "off") {
const existing = yield* schedule.list(input.sessionID)
return yield* Effect.forEach(existing, (info) => schedule.cancel(info.id), { discard: true })
}
const [interval = "", ...rest] = text.split(/\s+/)
const every = SessionSchedule.parseInterval(interval)
const prompt = rest.join(" ")
if (every === undefined || !prompt)
return yield* Effect.fail(new Error("Usage: /schedule <30s|15m|2h> <prompt>, or /schedule off"))
yield* schedule.create({ sessionID: input.sessionID, text: prompt, every })
}),
})
})
yield* ctx.tool
.transform((editor) => {
editor.add({
name: "schedule_create",
description:
"Schedule a prompt to be sent back to the current session later, even if no client has it open. Use `every` for recurring checks (e.g. every 15m), `at` for a single run at an absolute time, or both. Returns the schedule ID.",
input: CreateInput,
output: View,
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
if (input.every === undefined && input.at === undefined)
return yield* new ToolFailure({ message: "Provide `every`, `at`, or both" })
const every = input.every === undefined ? undefined : SessionSchedule.parseInterval(input.every)
if (input.every !== undefined && every === undefined)
return yield* new ToolFailure({ message: `Invalid interval ${input.every}; use e.g. 30s, 15m, 2h, 1d` })
const at = input.at === undefined ? undefined : Date.parse(input.at)
const now = yield* Clock.currentTimeMillis
if (at !== undefined && Number.isNaN(at))
return yield* new ToolFailure({ message: `Invalid timestamp ${input.at}` })
if (at !== undefined && at <= now)
return yield* new ToolFailure({
message: `${input.at} is in the past; the current time is ${new Date(now).toISOString()}`,
})
const info = yield* schedule.create({ sessionID: context.sessionID, text: input.prompt, at, every })
return {
output: view(info),
content: `Scheduled ${info.id}: next run ${view(info).next}${every ? `, then every ${input.every}` : ""}.`,
}
}),
})
editor.add({
name: "schedule_list",
description: "List the prompts scheduled for the current session, with the current server time.",
input: Schema.Struct({}),
output: ListOutput,
options: { namespace: "opencode", codemode: true },
execute: (_input, context) =>
Effect.gen(function* () {
const infos = yield* schedule.list(context.sessionID)
return {
output: {
now: new Date(yield* Clock.currentTimeMillis).toISOString(),
schedules: infos.map(view),
},
}
}),
})
editor.add({
name: "schedule_cancel",
description: "Cancel a scheduled prompt in the current session.",
input: CancelInput,
output: Schema.Struct({ id: Schema.String }),
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
const owned = (yield* schedule.list(context.sessionID)).some((info) => info.id === input.id)
if (!owned) return yield* new ToolFailure({ message: `No schedule ${input.id} in this session` })
yield* schedule.cancel(input.id)
return { output: { id: input.id }, content: `Cancelled ${input.id}.` }
}),
})
})
.pipe(Effect.orDie)
}),
})
-128
View File
@@ -1,128 +0,0 @@
export * as SessionSchedule from "./schedule.js"
import { Clock, Context, Duration, Effect, Fiber, Layer, Option, Schema } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Identifier } from "../id/id.js"
import { KV } from "../kv.js"
import { Session } from "../session.js"
import { SessionSchema } from "./schema.js"
export const Info = Schema.Struct({
id: Schema.String,
sessionID: SessionSchema.ID,
text: Schema.String,
/** Next fire time in epoch milliseconds. Persisted so restarts keep the cadence. */
next: Schema.Number,
/** Repeat interval in milliseconds; absent for one-shot schedules. */
every: Schema.optionalKey(Schema.Number),
})
export type Info = typeof Info.Type
const decodeInfo = Schema.decodeUnknownOption(Info)
const prefix = "session.schedule/"
const units = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }
/** Parses an interval such as `30s`, `15m`, `2h` or `1d` into milliseconds. */
export function parseInterval(input: string) {
const match = /^(\d+)([smhd])$/.exec(input.trim())
if (!match) return
const value = Number(match[1]) * units[match[2] as keyof typeof units]
return value > 0 ? value : undefined
}
export interface Interface {
/** Saves a prompt that fires once at `at`, or every `every` ms starting one interval from now. */
readonly create: (input: {
sessionID: SessionSchema.ID
text: string
at?: number
every?: number
}) => Effect.Effect<Info>
readonly cancel: (id: string) => Effect.Effect<void>
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<readonly Info[]>
/**
* Starts timers for every saved schedule. Timers live in this global service rather than in a
* Location, so they keep firing while the Session's Location is unloaded; each prompt loads it on
* demand. Overdue schedules fire once immediately. Inert until called: the managed server calls it
* once at boot.
*/
readonly resume: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSchedule") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const sessions = yield* Session.Service
const scope = yield* Effect.scope
const running = new Map<string, Fiber.Fiber<void>>()
const stop = Effect.fnUntraced(function* (id: string) {
const fiber = running.get(id)
running.delete(id)
if (fiber) yield* Fiber.interrupt(fiber)
})
const start = Effect.fnUntraced(function* (info: Info) {
yield* stop(info.id)
const loop = (current: Info): Effect.Effect<void> =>
Effect.gen(function* () {
const wait = current.next - (yield* Clock.currentTimeMillis)
if (wait > 0) yield* Effect.sleep(Duration.millis(wait))
const exists = yield* sessions
.prompt({ sessionID: current.sessionID, text: current.text, delivery: "queue" })
.pipe(
Effect.as(true),
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(false)),
Effect.catchCause((cause) =>
Effect.logWarning("scheduled prompt failed", { id: current.id, cause }).pipe(Effect.as(true)),
),
)
// One-shot schedules and schedules of deleted Sessions end here.
if (!exists || current.every === undefined) {
running.delete(current.id)
return yield* kv.remove(prefix + current.id)
}
// Skip occurrences missed while the server was down instead of firing them in a burst.
const now = yield* Clock.currentTimeMillis
const next = { ...current, next: Math.max(current.next + current.every, now + current.every) }
yield* kv.set(prefix + current.id, next)
return yield* loop(next)
})
running.set(info.id, yield* Effect.forkIn(loop(info), scope))
})
const all = Effect.gen(function* () {
const saved = yield* kv.scan({ prefix, limit: 1000 })
return saved.entries.flatMap((item) => Option.toArray(decodeInfo(item.value)))
})
return Service.of({
create: Effect.fnUntraced(function* (input) {
const now = yield* Clock.currentTimeMillis
const info: Info = {
id: Identifier.ascending("schedule"),
sessionID: input.sessionID,
text: input.text,
next: input.at ?? now + (input.every ?? 0),
...(input.every === undefined ? {} : { every: input.every }),
}
yield* kv.set(prefix + info.id, info)
yield* start(info)
return info
}),
cancel: Effect.fnUntraced(function* (id) {
yield* stop(id)
yield* kv.remove(prefix + id)
}),
list: (sessionID) => all.pipe(Effect.map((infos) => infos.filter((info) => info.sessionID === sessionID))),
resume: Effect.gen(function* () {
yield* Effect.forEach(yield* all, start, { discard: true })
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Session.node] })
+25 -1
View File
@@ -7,6 +7,24 @@ import { AgentNotFoundError, StepFailedError } from "./error.js"
import { ModelResolver } from "../model-resolver.js"
import { SessionRunnerModel } from "./runner/model.js"
const tokenSharingMessages = {
subscription_sharing_user_not_eligible:
"ChatGPT token sharing isn't available for this account. Connect with an API key or choose another provider.",
subscription_sharing_usage_limit_exceeded:
"ChatGPT usage limit reached. Check ChatGPT Settings → Usage for details.",
subscription_sharing_usage_unavailable: "ChatGPT usage can't be checked right now. Try again later.",
subscription_sharing_unsupported_capability:
"This request uses a feature ChatGPT token sharing doesn't support. Remove the unsupported feature and try again.",
subscription_sharing_route_not_supported:
"ChatGPT token sharing doesn't support this API route. Check the configured endpoint and HTTP method.",
subscription_sharing_invalid_user: "This ChatGPT connection is no longer valid. Reconnect to ChatGPT.",
subscription_sharing_user_unavailable: "Your ChatGPT account is temporarily unavailable. Try again later.",
chatpass_v2_scope_not_authorized:
"This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.",
chatpass_v2_invalid_authorization_context:
"This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.",
}
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AIError) {
switch (cause.reason._tag) {
@@ -41,6 +59,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
}
}
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof Permission.CorrectedError) return { type: "permission.rejected", message: cause.feedback }
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
@@ -68,5 +87,10 @@ export function toSessionError(cause: unknown): SessionError.Error {
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
const status = reason.http?.status
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
return {
type,
message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message,
...(status === undefined ? {} : { status }),
...(reason.body === undefined ? {} : { response: { body: reason.body } }),
}
}
+45
View File
@@ -199,4 +199,49 @@ describe("Credential", () => {
expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder)
}),
)
it.effect("creates credentials with a requested ID without taking over the current selection", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
const integrationID = Integration.ID.make("openai")
const events = new Array<Event.Payload>()
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
const first = yield* credentials.create({
id: Credential.ID.make("cred_0000imported"),
integrationID,
value: Credential.Key.make({ type: "key", key: "first" }),
activate: false,
})
expect(first.id).toBe(Credential.ID.make("cred_0000imported"))
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(first)
const legacy = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "legacy" }),
})
yield* database.db
.update(CredentialTable)
.set({ active: null })
.where(eq(CredentialTable.integration_id, integrationID))
.run()
.pipe(Effect.orDie)
const imported = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "imported" }),
activate: false,
})
expect(yield* credentials.list(integrationID)).toEqual([first, imported, legacy])
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: first.id } },
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: legacy.id } },
{ type: Credential.Event.Updated.type, data: {} },
])
}),
)
})
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { chmod, stat, writeFile } from "node:fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { Database } from "@opencode/core/database/database"
import { Global } from "@opencode/util/global"
import { tmpdir } from "./fixture/tmpdir"
// Read while the connection is open: SQLite may remove the sidecars on close.
const openModes = (filename: string) =>
Effect.runPromise(
Effect.gen(function* () {
yield* Layer.build(Database.layer({ path: filename }))
return yield* Effect.promise(() =>
Promise.all(["", "-wal", "-shm"].map(async (suffix) => (await stat(filename + suffix)).mode & 0o777)),
)
}).pipe(Effect.scoped, Effect.provideService(Global.Service, Global.make({ data: path.dirname(filename) }))),
)
describe.skipIf(process.platform === "win32")("Database file permissions", () => {
test("creates the database and sidecars readable only by the owner", async () => {
await using tmp = await tmpdir()
expect(await openModes(path.join(tmp.path, "opencode.db"))).toEqual([0o600, 0o600, 0o600])
})
test("tightens an existing database and sidecars", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode.db")
await Promise.all(
["", "-wal", "-shm"].map(async (suffix) => {
await writeFile(filename + suffix, "")
await chmod(filename + suffix, 0o644)
}),
)
expect(await openModes(filename)).toEqual([0o600, 0o600, 0o600])
})
})
File diff suppressed because it is too large Load Diff
+33
View File
@@ -110,6 +110,7 @@ describe("toSessionError", () => {
type: "provider.invalid-request",
message: "too large",
status: 413,
response: { body: '{"error":"context limit"}' },
})
expect(
toSessionError(
@@ -127,6 +128,38 @@ describe("toSessionError", () => {
})
})
test("preserves provider response body without HTTP status", () => {
expect(toSessionError(llm(new RateLimitError({ message: "Slow down", body: '{"error":"rate limit"}' })))).toEqual({
type: "provider.rate-limit",
message: "Slow down",
response: { body: '{"error":"rate limit"}' },
})
})
test("maps token-sharing failures while preserving HTTP and stream bodies", () => {
const cases = [
["subscription_sharing_user_not_eligible", "token sharing isn't available for this account"],
["subscription_sharing_usage_limit_exceeded", "ChatGPT usage limit reached"],
["subscription_sharing_usage_unavailable", "Try again later"],
["subscription_sharing_unsupported_capability", "Remove the unsupported feature"],
["subscription_sharing_route_not_supported", "Check the configured endpoint"],
["subscription_sharing_invalid_user", "Reconnect to ChatGPT"],
["subscription_sharing_user_unavailable", "Try again later"],
["chatpass_v2_scope_not_authorized", "isn't authorized for this request"],
["chatpass_v2_invalid_authorization_context", "isn't authorized for this request"],
] as const
for (const [code, guidance] of cases) {
for (const body of [
JSON.stringify({ error: { code, message: "Request failed" } }),
JSON.stringify({ type: "response.failed", response: { error: { code, message: "Request failed" } } }),
]) {
const error = toSessionError(llm(new UnknownProviderError({ message: "Request failed", body })))
expect(error.message).toContain(guidance)
expect(error.response).toEqual({ body })
}
}
})
test("preserves unresolved provider endpoint errors", () => {
const error = new ModelResolver.UnresolvedProviderVariablesError({
providerID: Provider.ID.make("cloudflare-workers-ai"),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.19",
"version": "2.0.20",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.19",
"version": "2.0.20",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"repository": {
@@ -1,8 +1,34 @@
import { Credential } from "@opencode/schema/credential"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ConflictError } from "../errors.js"
export const CredentialGroup = HttpApiGroup.make("server.credential")
.add(
HttpApiEndpoint.get("credential.list", "/api/credential", {
success: Schema.Struct({ data: Schema.Array(Credential.Entry) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "credential.list",
summary: "List credentials",
description: "List every stored integration credential, including its secret value.",
}),
),
)
.add(
HttpApiEndpoint.post("credential.create", "/api/credential", {
payload: Credential.CreateInput,
success: Schema.Struct({ data: Credential.Entry }),
error: ConflictError,
}).annotateMerge(
OpenApi.annotations({
identifier: "credential.create",
summary: "Create credential",
description:
"Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
}),
),
)
.add(
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID },
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"repository": {
+18
View File
@@ -50,3 +50,21 @@ export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
export interface Entry extends Schema.Schema.Type<typeof Entry> {}
export const Entry = Schema.Struct({
id: ID,
integrationID: IntegrationID,
label: Schema.String,
active: Schema.Boolean,
value: Value,
}).annotate({ identifier: "Credential.Entry" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const CreateInput = Schema.Struct({
id: optional(ID),
integrationID: IntegrationID,
label: optional(Schema.String),
value: Value,
activate: optional(Schema.Boolean),
}).annotate({ identifier: "Credential.CreateInput" })
+1
View File
@@ -8,4 +8,5 @@ export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
response: Schema.Struct({ body: Schema.String }).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
@@ -11,6 +11,7 @@ describe("SessionError", () => {
test("round trips current and future error types through JSON", () => {
const values: SessionError.Error[] = [
{ type: "provider.rate-limit", message: "Slow down" },
{ type: "provider.rate-limit", message: "Slow down", status: 429, response: { body: '{"error":{}}' } },
{ type: "provider.auth", message: "Authentication failed" },
{ type: "provider.future-condition", message: "A future provider failure" },
{ type: "unknown", message: "Unexpected" },
@@ -36,6 +37,9 @@ describe("SessionError", () => {
test("rejects missing envelope fields", () => {
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow()
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow()
expect(() =>
Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth", message: "Failed", response: {} }),
).toThrow()
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.19",
"version": "2.0.20",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.19",
"version": "2.0.20",
"type": "module",
"license": "MIT",
"repository": {
@@ -1,10 +1,32 @@
import { Credential } from "@opencode/core/credential"
import { ConflictError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) =>
handlers
.handle(
"credential.list",
Effect.fn(function* () {
const credential = yield* Credential.Service
return { data: entries(yield* credential.all()) }
}),
)
.handle(
"credential.create",
Effect.fn(function* (ctx) {
const credential = yield* Credential.Service
if (ctx.payload.id && (yield* credential.get(ctx.payload.id)))
return yield* new ConflictError({
resource: ctx.payload.id,
message: `Credential already exists: ${ctx.payload.id}`,
})
const created = yield* credential.create(ctx.payload)
const entry = entries(yield* credential.list(created.integrationID)).find((item) => item.id === created.id)
return { data: entry ?? { ...created, active: false } }
}),
)
.handle(
"credential.update",
Effect.fn(function* (ctx) {
@@ -30,3 +52,15 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
}),
),
)
// Credential listings order each integration's selected credential last.
function entries(credentials: Credential.Info[]) {
const selected = new Map(credentials.map((item) => [item.integrationID, item.id]))
return credentials.map((item) => ({
id: item.id,
integrationID: item.integrationID,
label: item.label,
active: selected.get(item.integrationID) === item.id,
value: item.value,
}))
}
-3
View File
@@ -3,7 +3,6 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode/core/bus"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
@@ -106,8 +105,6 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe(
Effect.provideService(Scope.Scope, applicationScope),
)
// Only the managed server restores saved schedules, so extra servers sharing the database never double-fire.
yield* Context.get(context, SessionSchedule.Service).resume
}
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
yield* status.ready
-2
View File
@@ -27,7 +27,6 @@ import { LocationServiceMap } from "@opencode/core/location-service-map"
import { LocationActivity } from "@opencode/core/location-activity"
import { ModelsDev } from "@opencode/core/models-dev"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { PluginUpdate } from "@opencode/core/plugin/update"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { WellKnown } from "@opencode/core/wellknown"
@@ -74,7 +73,6 @@ const applicationServiceNodes = [
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
SessionSchedule.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
@@ -0,0 +1,68 @@
import { expect, story } from "../../storybook/playwright/story"
const runs = [
"index.tsx, main.ts, rpc.ts, model.ts, toolbar.tsx",
"connection.ts limit=120, index.tsx, environment.tsx, env.d.ts",
"index.tsx, button.tsx, toggle.ts, index.tsx",
"context.tsx",
]
story("collapses each run of reads inside a Used group into one row", async ({ mount }) => {
const root = await mount("current-read-group--used-group", { args: { width: "960" } })
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used 65 Read, Shell", exact: true })).toBeVisible()
const reads = group.locator('[data-component="read-tool-group"]')
await expect(reads).toHaveCount(9)
await expect(reads.locator('[data-slot="basic-tool-tool-subtitle"]').first()).toHaveText(runs[0]!)
for (const [index, text] of runs.entries())
await expect(reads.nth(index).locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(text)
await expect(reads.first().locator('[data-slot="read-tool-group-file"]')).toHaveCount(5)
await expect(reads.nth(1).locator('[data-slot="read-tool-group-file"]').first()).toHaveAttribute(
"data-timeline-part-id",
"read_group_read_2_0",
)
await expect(group.locator('[data-slot="context-tool-group-item"]').nth(1)).toHaveText(/^Thought/)
const thought = group.locator('[data-component="reasoning-part"]').first()
const color = (locator: typeof thought, slot: string) =>
locator
.locator(`[data-slot="${slot}"]`)
.first()
.evaluate((node) => getComputedStyle(node).color)
expect(await color(reads.first(), "basic-tool-tool-title")).toBe(await color(thought, "basic-tool-tool-title"))
expect(await color(reads.first(), "basic-tool-tool-subtitle")).toBe(await color(thought, "basic-tool-tool-subtitle"))
const box = await reads.first().boundingBox()
expect(box?.height).toBe(28)
})
story("truncates long read rows and expands them in place", async ({ mount }) => {
const root = await mount("current-read-group--used-group", { args: { width: "420" } })
const reads = root.locator('[data-component="read-tool-group"]')
await expect(reads.first()).not.toHaveAttribute("role", "button")
const row = reads.filter({ hasText: "runner.ts" })
const files = row.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(row).toHaveAttribute("role", "button")
await expect(row).toHaveAttribute("aria-expanded", "false")
await expect(files).toHaveCSS("text-overflow", "ellipsis")
const collapsed = (await row.boundingBox())!.height
await row.click()
await expect(row).toHaveAttribute("aria-expanded", "true")
await expect(files).toHaveCSS("white-space", "normal")
expect((await row.boundingBox())!.height).toBeGreaterThan(collapsed)
expect(await files.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await row.press("Enter")
await expect(row).toHaveAttribute("aria-expanded", "false")
expect((await row.boundingBox())!.height).toBe(collapsed)
await row.press(" ")
await expect(row).toHaveAttribute("aria-expanded", "true")
})
story("merges adjacent reads when tools render outside a Used group", async ({ mount }) => {
const root = await mount("current-read-group--without-used-group", { args: { width: "960" } })
await expect(root.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
const reads = root.locator('[data-timeline-row="AssistantPart"] [data-component="read-tool-group"]')
await expect(reads).toHaveCount(9)
for (const [index, text] of runs.entries())
await expect(reads.nth(index).locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(text)
await expect(root.locator('[data-component="reasoning-part"]')).toHaveCount(8)
})
@@ -0,0 +1,30 @@
import { expect, story } from "../../storybook/playwright/story"
story("keeps the open Used header below the session title while scrolling", async ({ mount }) => {
const root = await mount("current-tool-group--sticky-header", { args: { height: "720" } })
const scroller = root.locator('[data-story="sticky-header-scroll"]')
const header = root.getByRole("button", { name: "Used 37 Write, Shell, Grep, Edit", exact: true })
const top = (locator: typeof header) =>
locator.evaluate((node) => {
const scroller = node.closest('[data-story="sticky-header-scroll"]')!
return node.getBoundingClientRect().top - scroller.getBoundingClientRect().top
})
expect(await scroller.evaluate((node) => node.scrollHeight > node.clientHeight * 2)).toBe(true)
await scroller.evaluate((node) => (node.scrollTop = 900))
await expect.poll(() => top(header)).toBe(48)
// Nested file headers stack below the stuck Used header instead of covering it.
const file = root.locator('[data-component="sticky-accordion-header"]').filter({ hasText: "model.ts" })
await file.evaluate((node) => {
const scroller = node.closest<HTMLElement>('[data-story="sticky-header-scroll"]')!
scroller.scrollTop += node.getBoundingClientRect().top - scroller.getBoundingClientRect().top + 60
})
await expect.poll(() => top(header)).toBe(48)
await expect.poll(() => top(file)).toBe(84)
await header.click()
await expect(header).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => top(header)).toBe(48)
await expect(root.locator('[data-slot="context-tool-group-item"]')).toHaveCount(0)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.19",
"version": "2.0.20",
"private": true,
"type": "module",
"license": "MIT",
@@ -652,8 +652,28 @@
}
}
[data-component="collapsed-tool-group"] > [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 8px;
[data-component="collapsed-tool-group"] {
--context-group-sticky-top: var(--sticky-accordion-top, 0px);
> [data-component="collapsible"].tool-collapsible {
--tool-content-gap: 8px;
gap: 0px;
> [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--context-group-sticky-top);
/* Above nested file headers as they scroll out under this row. */
z-index: 21;
height: calc(28px + var(--tool-content-gap));
padding-bottom: var(--tool-content-gap);
background-color: var(--v2-background-bg-base);
}
/* Nested sticky file headers stop below the open group header. */
> [data-slot="collapsible-content"] {
--sticky-accordion-top: calc(var(--context-group-sticky-top) + 28px + var(--tool-content-gap));
}
}
}
[data-component="context-tool-group-list"] {
@@ -1420,6 +1440,90 @@
}
}
[data-component="read-tool-group"] {
--read-tool-group-height: 32px;
width: 100%;
min-width: 0;
min-height: var(--read-tool-group-height);
display: flex;
align-items: center;
border-radius: var(--radius-md);
&[role="button"] {
cursor: pointer;
}
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
[data-slot="read-tool-group-file"] {
white-space: nowrap;
/* Args carry a leading space for their accessible text; the margin completes the 6px row gap. */
> [data-slot="basic-tool-tool-arg"] {
margin-inline-start: 2px;
}
}
[data-slot="read-tool-group-arrow"] {
flex-shrink: 0;
width: 16px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-weaker);
opacity: 0;
transform: rotate(-90deg);
transition:
opacity 0.15s ease,
transform 0.15s ease;
&:dir(rtl) {
transform: rotate(90deg);
}
@media (hover: none) {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&:hover [data-slot="read-tool-group-arrow"],
&:focus-visible [data-slot="read-tool-group-arrow"] {
opacity: 1;
}
&[data-expanded="true"] {
/* Wrapped lines use body line height; padding keeps the first line where the collapsed row placed it. */
padding-block: calc((var(--read-tool-group-height) - var(--line-height-base)) / 2);
> [data-component="tool-trigger"] {
align-items: flex-start;
}
[data-slot="basic-tool-tool-subtitle"] {
overflow: visible;
white-space: normal;
line-height: var(--line-height-base);
}
[data-slot="read-tool-group-arrow"] {
opacity: 1;
transform: none;
}
}
}
[data-slot="context-tool-group-item"] > [data-component="read-tool-group"] {
--read-tool-group-height: 28px;
}
:root body [data-component="user-message"] {
font-weight: 440;
@@ -1,12 +1,13 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode/client/promise"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode/client/promise"
import { Match, Switch, type ComponentProps } from "solid-js"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
import {
CurrentContextToolGroup,
CurrentFileToolGroup,
CurrentReadToolGroup,
ToolDisplay,
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
@@ -102,6 +103,10 @@ export function SessionContextToolGroup(props: ComponentProps<typeof CurrentCont
return <CurrentContextToolGroup {...props} />
}
export function SessionReadToolGroup(props: ComponentProps<typeof CurrentReadToolGroup>) {
return <CurrentReadToolGroup {...props} />
}
export function SessionFileToolGroup(props: {
tools: SessionMessageAssistantTool[]
fileOpen: (path: string) => boolean | undefined
@@ -65,6 +65,21 @@ export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) {
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
export function readImagePath(input: Record<string, unknown>) {
if (typeof input.path !== "string" || !/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(input.path)) return
return input.path.replaceAll("\\", "/")
}
// Plain file reads render as one comma-separated row; images and loaded instructions keep their own rows.
export function currentToolGroupedRead(tool: SessionMessageAssistantTool) {
return (
tool.name === "read" &&
tool.state.status !== "error" &&
!readImagePath(currentToolInput(tool)) &&
!currentToolHasLoadedFiles(tool)
)
}
export function currentContentDefaultOpen(
content: SessionMessageAssistant["content"][number],
shellExpanded: boolean,
@@ -8,7 +8,12 @@ import type {
} from "@opencode/client/promise"
import { Option, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
import {
currentContentDefaultOpen,
currentToolFailed,
currentToolGroupedRead,
currentToolHasLoadedFiles,
} from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
@@ -602,7 +607,7 @@ function groupContent(
detail?: TimelineDetail,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
let adjacent: { type: "context" | "file" | "read"; refs: PartRef[]; tools: boolean } | undefined
const flush = () => {
const current = adjacent
const first = current?.refs[0]
@@ -615,7 +620,7 @@ function groupContent(
return
}
groups.push({
type: current.type === "context" ? "context" : "file",
type: current.type,
key:
current.type !== "context"
? `part:${first.messageID}:${first.partID}`
@@ -669,6 +674,7 @@ function toolGroupType(
if (content.name === "question" && !currentToolFailed(content)) return undefined
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (currentToolGroupedRead(content)) return "read"
if (currentToolFailed(content)) return undefined
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
return undefined
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode/client/promise"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { timelinePresets } from "./detail"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
@@ -862,6 +858,52 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("merges adjacent separate reads into one read row split by other parts", () => {
const rows = Timeline.constructSessionMessageRows(
storyDocument([
storyTool("read_1", "read", "completed", { path: "src/a.ts" }),
storyTool("read_2", "read", "running", { path: "src/b.ts", limit: 120 }),
{ type: "reasoning", text: "Keep reading." },
storyTool("read_3", "read", "completed", { path: "src/c.ts" }),
storyTool("read_image", "read", "completed", { path: "src/logo.png" }),
storyTool("read_failed", "read", "error", { path: "src/missing.ts" }),
storyTool("read_4", "read", "completed", { path: "src/d.ts" }),
storyTool("read_5", "read", "completed", { path: "src/e.ts" }),
storyTool("glob", "glob", "completed", { pattern: "*.ts" }),
storyTool("read_6", "read", "completed", { path: "src/f.ts" }),
]).messages,
true,
{ type: "idle" },
undefined,
false,
false,
undefined,
timelinePresets[0].value,
).rows
expect(
rows.flatMap((row) =>
row._tag !== "AssistantPart"
? []
: [
[
row.group.type,
...(row.group.type === "part" ? [row.group.ref] : row.group.refs).map((ref) => ref.partID),
],
],
),
).toEqual([
["read", "read_1", "read_2"],
["part", "msg_tool_projection_assistant:reasoning:0"],
["read", "read_3"],
["part", "read_image"],
["part", "read_failed"],
["read", "read_4", "read_5"],
["part", "glob"],
["read", "read_6"],
])
})
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
@@ -14,6 +14,7 @@ import {
SessionAssistantContent,
SessionContextToolGroup,
SessionFileToolGroup,
SessionReadToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -210,6 +211,26 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (row().group.type === "read") {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "read") return []
const contents = indexGroupContents(group.refs)
return group.refs.flatMap((ref) => {
const content = contents.get(ref.messageID)?.get(ref.partID)
return content?.type === "tool" ? [content] : []
})
})
return (
<SessionReadToolGroup
tools={tools()}
open={input.disclosure.value(row().group.key)}
onOpenChange={(open) => input.disclosure.set(row().group.key, open)}
onSizeChange={onSizeChange}
/>
)
}
if (row().group.type === "file") {
const tools = createMemo(() => {
const group = row().group

Some files were not shown because too many files have changed in this diff Show More