Skip to content

Commit a8983bd

Browse files
authored
feat(codemode): add OpenAPI tool adapter (#35192)
1 parent 709af58 commit a8983bd

16 files changed

Lines changed: 29461 additions & 62 deletions

File tree

packages/codemode/AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
66
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
77

8+
## OpenAPI
9+
10+
- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason.
11+
- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed.
12+
- Render unresolved schema constructs as `unknown`, never as invented TypeScript names.
13+
- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values.
14+
- Test supported behavior directly; do not reproduce adapter algorithms in tests.
15+
816
## Future Design Notes
917

1018
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.

packages/codemode/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,33 @@ interface ExecuteFailure {
152152

153153
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
154154

155+
### OpenAPI tools
156+
157+
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
158+
159+
```ts
160+
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
161+
import { Effect } from "effect"
162+
import { FetchHttpClient } from "effect/unstable/http"
163+
164+
const api = OpenAPI.fromSpec({
165+
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
166+
auth: {
167+
resolve: ({ name, scopes, operation }) =>
168+
name === "BearerAuth"
169+
? Effect.succeed({ type: "bearer", token })
170+
: Effect.succeed(undefined),
171+
},
172+
})
173+
174+
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
175+
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
176+
```
177+
178+
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
179+
180+
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
181+
155182
## Discovery
156183

157184
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).

packages/codemode/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
22
export { Tool } from "./tool.js"
3+
export * as OpenAPI from "./openapi/index.js"
34
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
45
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
56
export type {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# OpenAPI Follow-ups
2+
3+
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
4+
5+
- Cookie parameters, authentication, and cookie-header merging.
6+
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
7+
- External references and complete nested `$defs` support.
8+
- Relative or templated server URLs and server variables.
9+
- Base URLs containing query strings or fragments.
10+
- Runtime response-schema validation and full content negotiation.
11+
- Binary response values and explicit byte-oriented return types.
12+
- Request/response projection for `readOnly` and `writeOnly` properties.
13+
- SSE, WebSocket, and other streaming transports.
14+
- Recovery of responses rejected by a status-filtering `HttpClient`.
15+
- Configurable request and response size limits.
16+
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
17+
- Strict UTF-8 and empty-body validation for JSON responses.
18+
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
19+
- Complete malformed-security-scheme validation and broader auth-combination coverage.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { HttpClient } from "effect/unstable/http"
2+
import { Tool, type Definition } from "../tool.js"
3+
import { invoke } from "./runtime.js"
4+
import {
5+
componentDefinitions,
6+
inputSchema,
7+
isRecord,
8+
methods,
9+
nonEmptyString,
10+
operationInput,
11+
operationOutput,
12+
operationPath,
13+
operationSecurityRequirements,
14+
securityRequirements,
15+
securitySchemes,
16+
specServerUrl,
17+
validateBaseUrl,
18+
} from "./spec.js"
19+
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
20+
21+
export type {
22+
AuthResolver,
23+
Credential,
24+
Document,
25+
Operation,
26+
Options,
27+
Result,
28+
SecurityScheme,
29+
Skipped,
30+
Tools,
31+
} from "./types.js"
32+
33+
/**
34+
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
35+
* operation. Auth is resolved host-side via `auth.resolve` and never
36+
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
37+
* operations land in `skipped`.
38+
*/
39+
export const fromSpec = (options: Options): Result => {
40+
const document = options.spec
41+
const schemes = securitySchemes(document)
42+
const defaultSecurity = securityRequirements(document.security)
43+
const definitions = componentDefinitions(document)
44+
const paths = isRecord(document.paths) ? document.paths : {}
45+
const used = new Set<string>()
46+
const namespaces = new Set<string>()
47+
const skipped: Array<Skipped> = []
48+
const tools = Object.create(null) as Tools
49+
50+
for (const [path, pathValue] of Object.entries(paths)) {
51+
if (!isRecord(pathValue)) continue
52+
for (const [method, operationValue] of Object.entries(pathValue)) {
53+
if (!methods.has(method) || !isRecord(operationValue)) continue
54+
const segments = operationPath(method, path, operationValue, used, namespaces)
55+
const operation: Operation = {
56+
operationId: nonEmptyString(operationValue.operationId),
57+
method: method.toUpperCase(),
58+
path,
59+
summary: nonEmptyString(operationValue.summary),
60+
description: nonEmptyString(operationValue.description),
61+
}
62+
const output = operationOutput(document, operationValue, definitions)
63+
if (!output.ok) {
64+
skipped.push({ method: operation.method, path, reason: output.reason })
65+
continue
66+
}
67+
68+
const resolvedBaseUrl = (() => {
69+
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
70+
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
71+
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
72+
return specServerUrl(document)
73+
})()
74+
if (!resolvedBaseUrl.ok) {
75+
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
76+
continue
77+
}
78+
const parsedInput = operationInput(document, pathValue, operationValue)
79+
if (!parsedInput.ok) {
80+
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
81+
continue
82+
}
83+
const input = parsedInput.value
84+
85+
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
86+
if (!security.ok) {
87+
skipped.push({ method: operation.method, path, reason: security.reason })
88+
continue
89+
}
90+
const plan = {
91+
operation,
92+
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
93+
fields: input.fields,
94+
body: input.body,
95+
security: security.value,
96+
schemes,
97+
auth: options.auth,
98+
headers: options.headers ?? {},
99+
}
100+
used.add(segments.join("."))
101+
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
102+
setTool(
103+
tools,
104+
segments,
105+
Tool.make({
106+
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
107+
input: inputSchema(input.fields, definitions),
108+
output: output.value,
109+
run: (input) => invoke(plan, input),
110+
}),
111+
)
112+
}
113+
}
114+
115+
return { tools, skipped }
116+
}
117+
118+
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
119+
const [head, ...rest] = path
120+
if (head === undefined) return
121+
if (rest.length === 0) {
122+
tools[head] = definition
123+
return
124+
}
125+
const child = tools[head]
126+
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
127+
tools[head] = Object.create(null) as Tools
128+
}
129+
setTool(tools[head] as Tools, rest, definition)
130+
}

0 commit comments

Comments
 (0)