summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-30 22:38:32 -0400
committerGitHub <[email protected]>2026-05-01 02:38:32 +0000
commit3c24d22d42b2791c967b571db2c6e77e68ab38c5 (patch)
tree0459c36a9453956cefbdb81b55f8ca9f5758fe3f
parent4c70ea28d2a44941ea65729863d0fa6e965321ce (diff)
downloadopencode-3c24d22d42b2791c967b571db2c6e77e68ab38c5.tar.gz
opencode-3c24d22d42b2791c967b571db2c6e77e68ab38c5.zip
fix(httpapi): omit absent optional response fields (#25214)
-rw-r--r--packages/opencode/src/project/project.ts20
-rw-r--r--packages/opencode/src/provider/auth.ts40
-rw-r--r--packages/opencode/src/provider/provider.ts12
-rw-r--r--packages/opencode/test/server/httpapi-json-parity.test.ts81
4 files changed, 118 insertions, 35 deletions
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index 86208a60c..f30d2e90c 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod"
-import { NonNegativeInt, withStatics } from "@/util/schema"
+import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
import { serviceUse } from "@/effect/service-use"
const log = Log.create({ service: "project" })
@@ -24,13 +24,13 @@ const log = Log.create({ service: "project" })
const ProjectVcs = Schema.Literal("git")
const ProjectIcon = Schema.Struct({
- url: Schema.optional(Schema.String),
- override: Schema.optional(Schema.String),
- color: Schema.optional(Schema.String),
+ url: optionalOmitUndefined(Schema.String),
+ override: optionalOmitUndefined(Schema.String),
+ color: optionalOmitUndefined(Schema.String),
})
const ProjectCommands = Schema.Struct({
- start: Schema.optional(
+ start: optionalOmitUndefined(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
),
})
@@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({
const ProjectTime = Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
- initialized: Schema.optional(NonNegativeInt),
+ initialized: optionalOmitUndefined(NonNegativeInt),
})
export const Info = Schema.Struct({
id: ProjectID,
worktree: Schema.String,
- vcs: Schema.optional(ProjectVcs),
- name: Schema.optional(Schema.String),
- icon: Schema.optional(ProjectIcon),
- commands: Schema.optional(ProjectCommands),
+ vcs: optionalOmitUndefined(ProjectVcs),
+ name: optionalOmitUndefined(Schema.String),
+ icon: optionalOmitUndefined(ProjectIcon),
+ commands: optionalOmitUndefined(ProjectCommands),
time: ProjectTime,
sandboxes: Schema.Array(Schema.String),
})
diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts
index 6cbfcf1be..9b2ca33c3 100644
--- a/packages/opencode/src/provider/auth.ts
+++ b/packages/opencode/src/provider/auth.ts
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod"
import { namedSchemaError } from "@/util/named-schema-error"
-import { withStatics } from "@/util/schema"
+import { optionalOmitUndefined, withStatics } from "@/util/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
- placeholder: Schema.optional(Schema.String),
- when: Schema.optional(When),
+ placeholder: optionalOmitUndefined(Schema.String),
+ when: optionalOmitUndefined(When),
})
const SelectOption = Schema.Struct({
label: Schema.String,
value: Schema.String,
- hint: Schema.optional(Schema.String),
+ hint: optionalOmitUndefined(Schema.String),
})
const SelectPrompt = Schema.Struct({
@@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({
key: Schema.String,
message: Schema.String,
options: Schema.Array(SelectOption),
- when: Schema.optional(When),
+ when: optionalOmitUndefined(When),
})
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
@@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt])
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
type: Schema.Literals(["oauth", "api"]),
label: Schema.String,
- prompts: Schema.optional(Schema.Array(Prompt)),
+ prompts: optionalOmitUndefined(Schema.Array(Prompt)),
}) {
static readonly zod = zod(this)
}
@@ -135,23 +135,25 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
item.methods.map((method) => ({
type: method.type,
label: method.label,
- prompts: method.prompts?.map((prompt) => {
- if (prompt.type === "select") {
+ ...(method.prompts && {
+ prompts: method.prompts.map((prompt) => {
+ if (prompt.type === "select") {
+ return {
+ type: "select" as const,
+ key: prompt.key,
+ message: prompt.message,
+ options: prompt.options,
+ ...(prompt.when && { when: prompt.when }),
+ }
+ }
return {
- type: "select" as const,
+ type: "text" as const,
key: prompt.key,
message: prompt.message,
- options: prompt.options,
- when: prompt.when,
+ ...(prompt.placeholder && { placeholder: prompt.placeholder }),
+ ...(prompt.when && { when: prompt.when }),
}
- }
- return {
- type: "text" as const,
- key: prompt.key,
- message: prompt.message,
- placeholder: prompt.placeholder,
- when: prompt.when,
- }
+ }),
}),
})),
),
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 24b599db0..7d9806d13 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
-import { withStatics } from "@/util/schema"
+import { optionalOmitUndefined, withStatics } from "@/util/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
@@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache: ProviderCacheCost,
- experimentalOver200K: Schema.optional(
+ experimentalOver200K: optionalOmitUndefined(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
@@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({
const ProviderLimit = Schema.Struct({
context: Schema.Finite,
- input: Schema.optional(Schema.Finite),
+ input: optionalOmitUndefined(Schema.Finite),
output: Schema.Finite,
})
@@ -895,7 +895,7 @@ export const Model = Schema.Struct({
providerID: ProviderID,
api: ProviderApiInfo,
name: Schema.String,
- family: Schema.optional(Schema.String),
+ family: optionalOmitUndefined(Schema.String),
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
@@ -903,7 +903,7 @@ export const Model = Schema.Struct({
options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
- variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
+ variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
})
.annotate({ identifier: "Model" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -914,7 +914,7 @@ export const Info = Schema.Struct({
name: Schema.String,
source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String),
- key: Schema.optional(Schema.String),
+ key: optionalOmitUndefined(Schema.String),
options: Schema.Record(Schema.String, Schema.Any),
models: Schema.Record(Schema.String, Model),
})
diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts
index b88a032f5..645e924c6 100644
--- a/packages/opencode/test/server/httpapi-json-parity.test.ts
+++ b/packages/opencode/test/server/httpapi-json-parity.test.ts
@@ -5,6 +5,10 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
+import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
+import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
+import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
+import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session"
@@ -90,6 +94,83 @@ afterEach(async () => {
describe("HttpApi JSON parity", () => {
it.live(
+ "matches legacy JSON shape for safe GET endpoints",
+ withTmp(
+ {
+ git: true,
+ config: {
+ formatter: false,
+ lsp: false,
+ mcp: {
+ demo: {
+ type: "local",
+ command: ["echo", "demo"],
+ enabled: false,
+ },
+ },
+ },
+ },
+ (tmp) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
+
+ const headers = { "x-opencode-directory": tmp.path }
+ const legacy = app(false)
+ const httpapi = app(true)
+
+ yield* Effect.forEach(
+ [
+ { label: "global.health", path: GlobalPaths.health, headers: {} },
+ { label: "instance.path", path: InstancePaths.path, headers },
+ { label: "instance.vcs", path: InstancePaths.vcs, headers },
+ { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
+ { label: "instance.command", path: InstancePaths.command, headers },
+ { label: "instance.agent", path: InstancePaths.agent, headers },
+ { label: "instance.skill", path: InstancePaths.skill, headers },
+ { label: "instance.lsp", path: InstancePaths.lsp, headers },
+ { label: "instance.formatter", path: InstancePaths.formatter, headers },
+ { label: "config.get", path: "/config", headers },
+ { label: "config.providers", path: "/config/providers", headers },
+ { label: "project.list", path: "/project", headers },
+ { label: "project.current", path: "/project/current", headers },
+ { label: "provider.list", path: "/provider", headers },
+ { label: "provider.auth", path: "/provider/auth", headers },
+ { label: "mcp.status", path: McpPaths.status, headers },
+ { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
+ {
+ label: "file.content",
+ path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
+ headers,
+ },
+ { label: "file.status", path: FilePaths.status, headers },
+ {
+ label: "find.file",
+ path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
+ headers,
+ },
+ {
+ label: "find.text",
+ path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
+ headers,
+ },
+ {
+ label: "find.symbol",
+ path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
+ headers,
+ },
+ { label: "experimental.console", path: ExperimentalPaths.console, headers },
+ { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
+ { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
+ { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
+ ],
+ (input) => expectJsonParity({ ...input, legacy, httpapi }),
+ { concurrency: 1 },
+ )
+ }),
+ ),
+ )
+
+ it.live(
"matches legacy JSON shape for session read endpoints",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {