Skip to content

Commit 3c24d22

Browse files
authored
fix(httpapi): omit absent optional response fields (anomalyco#25214)
1 parent 4c70ea2 commit 3c24d22

4 files changed

Lines changed: 118 additions & 35 deletions

File tree

packages/opencode/src/project/project.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,38 @@ import { NodePath } from "@effect/platform-node"
1616
import { AppFileSystem } from "@opencode-ai/core/filesystem"
1717
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
1818
import { zod } from "@/util/effect-zod"
19-
import { NonNegativeInt, withStatics } from "@/util/schema"
19+
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
2020
import { serviceUse } from "@/effect/service-use"
2121

2222
const log = Log.create({ service: "project" })
2323

2424
const ProjectVcs = Schema.Literal("git")
2525

2626
const ProjectIcon = Schema.Struct({
27-
url: Schema.optional(Schema.String),
28-
override: Schema.optional(Schema.String),
29-
color: Schema.optional(Schema.String),
27+
url: optionalOmitUndefined(Schema.String),
28+
override: optionalOmitUndefined(Schema.String),
29+
color: optionalOmitUndefined(Schema.String),
3030
})
3131

3232
const ProjectCommands = Schema.Struct({
33-
start: Schema.optional(
33+
start: optionalOmitUndefined(
3434
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
3535
),
3636
})
3737

3838
const ProjectTime = Schema.Struct({
3939
created: NonNegativeInt,
4040
updated: NonNegativeInt,
41-
initialized: Schema.optional(NonNegativeInt),
41+
initialized: optionalOmitUndefined(NonNegativeInt),
4242
})
4343

4444
export const Info = Schema.Struct({
4545
id: ProjectID,
4646
worktree: Schema.String,
47-
vcs: Schema.optional(ProjectVcs),
48-
name: Schema.optional(Schema.String),
49-
icon: Schema.optional(ProjectIcon),
50-
commands: Schema.optional(ProjectCommands),
47+
vcs: optionalOmitUndefined(ProjectVcs),
48+
name: optionalOmitUndefined(Schema.String),
49+
icon: optionalOmitUndefined(ProjectIcon),
50+
commands: optionalOmitUndefined(ProjectCommands),
5151
time: ProjectTime,
5252
sandboxes: Schema.Array(Schema.String),
5353
})

packages/opencode/src/provider/auth.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
33
import { InstanceState } from "@/effect/instance-state"
44
import { zod } from "@/util/effect-zod"
55
import { namedSchemaError } from "@/util/named-schema-error"
6-
import { withStatics } from "@/util/schema"
6+
import { optionalOmitUndefined, withStatics } from "@/util/schema"
77
import { Plugin } from "../plugin"
88
import { ProviderID } from "./schema"
99
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
@@ -18,30 +18,30 @@ const TextPrompt = Schema.Struct({
1818
type: Schema.Literal("text"),
1919
key: Schema.String,
2020
message: Schema.String,
21-
placeholder: Schema.optional(Schema.String),
22-
when: Schema.optional(When),
21+
placeholder: optionalOmitUndefined(Schema.String),
22+
when: optionalOmitUndefined(When),
2323
})
2424

2525
const SelectOption = Schema.Struct({
2626
label: Schema.String,
2727
value: Schema.String,
28-
hint: Schema.optional(Schema.String),
28+
hint: optionalOmitUndefined(Schema.String),
2929
})
3030

3131
const SelectPrompt = Schema.Struct({
3232
type: Schema.Literal("select"),
3333
key: Schema.String,
3434
message: Schema.String,
3535
options: Schema.Array(SelectOption),
36-
when: Schema.optional(When),
36+
when: optionalOmitUndefined(When),
3737
})
3838

3939
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
4040

4141
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
4242
type: Schema.Literals(["oauth", "api"]),
4343
label: Schema.String,
44-
prompts: Schema.optional(Schema.Array(Prompt)),
44+
prompts: optionalOmitUndefined(Schema.Array(Prompt)),
4545
}) {
4646
static readonly zod = zod(this)
4747
}
@@ -135,23 +135,25 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
135135
item.methods.map((method) => ({
136136
type: method.type,
137137
label: method.label,
138-
prompts: method.prompts?.map((prompt) => {
139-
if (prompt.type === "select") {
138+
...(method.prompts && {
139+
prompts: method.prompts.map((prompt) => {
140+
if (prompt.type === "select") {
141+
return {
142+
type: "select" as const,
143+
key: prompt.key,
144+
message: prompt.message,
145+
options: prompt.options,
146+
...(prompt.when && { when: prompt.when }),
147+
}
148+
}
140149
return {
141-
type: "select" as const,
150+
type: "text" as const,
142151
key: prompt.key,
143152
message: prompt.message,
144-
options: prompt.options,
145-
when: prompt.when,
153+
...(prompt.placeholder && { placeholder: prompt.placeholder }),
154+
...(prompt.when && { when: prompt.when }),
146155
}
147-
}
148-
return {
149-
type: "text" as const,
150-
key: prompt.key,
151-
message: prompt.message,
152-
placeholder: prompt.placeholder,
153-
when: prompt.when,
154-
}
156+
}),
155157
}),
156158
})),
157159
),

packages/opencode/src/provider/provider.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
2424
import { InstanceState } from "@/effect/instance-state"
2525
import { AppFileSystem } from "@opencode-ai/core/filesystem"
2626
import { isRecord } from "@/util/record"
27-
import { withStatics } from "@/util/schema"
27+
import { optionalOmitUndefined, withStatics } from "@/util/schema"
2828

2929
import * as ProviderTransform from "./transform"
3030
import { ModelID, ProviderID } from "./schema"
@@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({
875875
input: Schema.Finite,
876876
output: Schema.Finite,
877877
cache: ProviderCacheCost,
878-
experimentalOver200K: Schema.optional(
878+
experimentalOver200K: optionalOmitUndefined(
879879
Schema.Struct({
880880
input: Schema.Finite,
881881
output: Schema.Finite,
@@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({
886886

887887
const ProviderLimit = Schema.Struct({
888888
context: Schema.Finite,
889-
input: Schema.optional(Schema.Finite),
889+
input: optionalOmitUndefined(Schema.Finite),
890890
output: Schema.Finite,
891891
})
892892

@@ -895,15 +895,15 @@ export const Model = Schema.Struct({
895895
providerID: ProviderID,
896896
api: ProviderApiInfo,
897897
name: Schema.String,
898-
family: Schema.optional(Schema.String),
898+
family: optionalOmitUndefined(Schema.String),
899899
capabilities: ProviderCapabilities,
900900
cost: ProviderCost,
901901
limit: ProviderLimit,
902902
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
903903
options: Schema.Record(Schema.String, Schema.Any),
904904
headers: Schema.Record(Schema.String, Schema.String),
905905
release_date: Schema.String,
906-
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
906+
variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
907907
})
908908
.annotate({ identifier: "Model" })
909909
.pipe(withStatics((s) => ({ zod: zod(s) })))
@@ -914,7 +914,7 @@ export const Info = Schema.Struct({
914914
name: Schema.String,
915915
source: Schema.Literals(["env", "config", "custom", "api"]),
916916
env: Schema.Array(Schema.String),
917-
key: Schema.optional(Schema.String),
917+
key: optionalOmitUndefined(Schema.String),
918918
options: Schema.Record(Schema.String, Schema.Any),
919919
models: Schema.Record(Schema.String, Model),
920920
})

packages/opencode/test/server/httpapi-json-parity.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
55
import { Instance } from "../../src/project/instance"
66
import { Server } from "../../src/server/server"
77
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
8+
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
9+
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
10+
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
11+
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
812
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
913
import { MessageID, PartID } from "../../src/session/schema"
1014
import { Session } from "@/session/session"
@@ -89,6 +93,83 @@ afterEach(async () => {
8993
})
9094

9195
describe("HttpApi JSON parity", () => {
96+
it.live(
97+
"matches legacy JSON shape for safe GET endpoints",
98+
withTmp(
99+
{
100+
git: true,
101+
config: {
102+
formatter: false,
103+
lsp: false,
104+
mcp: {
105+
demo: {
106+
type: "local",
107+
command: ["echo", "demo"],
108+
enabled: false,
109+
},
110+
},
111+
},
112+
},
113+
(tmp) =>
114+
Effect.gen(function* () {
115+
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
116+
117+
const headers = { "x-opencode-directory": tmp.path }
118+
const legacy = app(false)
119+
const httpapi = app(true)
120+
121+
yield* Effect.forEach(
122+
[
123+
{ label: "global.health", path: GlobalPaths.health, headers: {} },
124+
{ label: "instance.path", path: InstancePaths.path, headers },
125+
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
126+
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
127+
{ label: "instance.command", path: InstancePaths.command, headers },
128+
{ label: "instance.agent", path: InstancePaths.agent, headers },
129+
{ label: "instance.skill", path: InstancePaths.skill, headers },
130+
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
131+
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
132+
{ label: "config.get", path: "/config", headers },
133+
{ label: "config.providers", path: "/config/providers", headers },
134+
{ label: "project.list", path: "/project", headers },
135+
{ label: "project.current", path: "/project/current", headers },
136+
{ label: "provider.list", path: "/provider", headers },
137+
{ label: "provider.auth", path: "/provider/auth", headers },
138+
{ label: "mcp.status", path: McpPaths.status, headers },
139+
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
140+
{
141+
label: "file.content",
142+
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
143+
headers,
144+
},
145+
{ label: "file.status", path: FilePaths.status, headers },
146+
{
147+
label: "find.file",
148+
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
149+
headers,
150+
},
151+
{
152+
label: "find.text",
153+
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
154+
headers,
155+
},
156+
{
157+
label: "find.symbol",
158+
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
159+
headers,
160+
},
161+
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
162+
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
163+
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
164+
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
165+
],
166+
(input) => expectJsonParity({ ...input, legacy, httpapi }),
167+
{ concurrency: 1 },
168+
)
169+
}),
170+
),
171+
)
172+
92173
it.live(
93174
"matches legacy JSON shape for session read endpoints",
94175
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>

0 commit comments

Comments
 (0)