fix(httpapi): omit absent optional response fields (#25214)

This commit is contained in:
Kit Langton 2026-04-30 22:38:32 -04:00 committed by GitHub
parent 4c70ea28d2
commit 3c24d22d42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 35 deletions

View file

@ -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),
})

View file

@ -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,
}
}),
}),
})),
),

View file

@ -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),
})

View file

@ -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"
@ -89,6 +93,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) =>