Explorar el Código

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

Kit Langton hace 4 meses
padre
commit
3c24d22d42

+ 10 - 10
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),
 })

+ 21 - 19
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,
-              }
+              }),
             }),
           })),
         ),

+ 6 - 6
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),
 })

+ 81 - 0
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"
@@ -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) =>