diff --git a/src/index.ts b/src/index.ts index 12bf94b..61d1668 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,7 +46,12 @@ export async function configureOpencodeHostTransport(ctx: { const serverUrl = hostConfig.baseUrl ?? ctx.serverUrl; if (serverUrl) { - setV2Client(createV2Client(serverUrl)); + setV2Client( + createV2Client(serverUrl, { + fetch: hostConfig.fetch, + headers: hostConfig.headers, + }) + ); } } diff --git a/src/services/ai/opencode-host-config.ts b/src/services/ai/opencode-host-config.ts index 05f6b91..a1a67da 100644 --- a/src/services/ai/opencode-host-config.ts +++ b/src/services/ai/opencode-host-config.ts @@ -1,6 +1,7 @@ export type HostClientConfig = { readonly baseUrl: string | undefined; readonly fetch: typeof fetch | undefined; + readonly headers?: RequestInit["headers"]; readonly clientKeys: readonly string[]; readonly sdkConfigCount: number; }; @@ -14,10 +15,12 @@ export function getHostClientConfig(ctx: { readonly client: unknown }): HostClie const configs = sdkConfigs(client); const baseUrl = configs.find((config) => typeof config["baseUrl"] === "string")?.["baseUrl"]; const customFetch = configs.find((config) => isFetch(config["fetch"]))?.["fetch"]; + const headers = configs.find((config) => isHeadersInit(config["headers"]))?.["headers"]; return { baseUrl: typeof baseUrl === "string" ? baseUrl : undefined, fetch: isFetch(customFetch) ? customFetch : undefined, + ...(isHeadersInit(headers) ? { headers } : {}), clientKeys: Object.keys(client), sdkConfigCount: configs.length, }; @@ -55,3 +58,11 @@ function isConfigGetter(value: unknown): value is (this: unknown) => unknown { function isFetch(value: unknown): value is typeof fetch { return typeof value === "function"; } + +function isHeadersInit(value: unknown): value is RequestInit["headers"] { + return ( + value instanceof Headers || + Array.isArray(value) || + (typeof value === "object" && value !== null) + ); +} diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index a979cce..b6efd2b 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -11,16 +11,9 @@ * then delete the session so it does not pollute the user's TUI session * list. * - * We intentionally bypass the `@opencode-ai/sdk` client for these three - * endpoints. Issue #110 showed that relying on `client.session.create` / - * `client.session.prompt` / `client.session.delete` is brittle: the SDK - * class layout has shifted across releases (e.g. v1.14.48's `Session` only - * exposes `list()` in some builds, with the real methods living on a - * renamed `Session2` reachable via a different property path). Going - * straight to `fetch` against the documented server endpoints - * (`POST /session`, `POST /session/{id}/message`, `DELETE /session/{id}`) - * makes us resilient to those SDK churns and lets us test the wire - * protocol directly with a `globalThis.fetch` stub. + * The primary transport is the authenticated v2 SDK client initialized from + * the plugin host's client configuration. A raw fetch fallback remains for + * older SDK builds that do not expose the v2 session methods. */ import type { z } from "zod"; @@ -31,12 +24,13 @@ import { responseStatus, type FetchEndpoint, } from "./opencode-diagnostics.js"; -import { createLazyV2Client } from "./opencode-sdk-client.js"; +import { createLazyV2Client, type HostTransport } from "./opencode-sdk-client.js"; let _connectedProviders: Set = new Set(); let _v2Client: OpencodeClient | undefined; let _v2BaseUrl: string | undefined; let _hostFetch: typeof fetch | undefined; +let _useSdkTransport = false; export function setHostFetch(customFetch: typeof fetch): void { _hostFetch = customFetch; @@ -62,10 +56,12 @@ export function getV2Client(): OpencodeClient | undefined { return _v2Client; } -export function createV2Client(serverUrl: URL | string): OpencodeClient { +export function createV2Client(serverUrl: URL | string, transport?: HostTransport): OpencodeClient { const baseUrl = typeof serverUrl === "string" ? serverUrl : serverUrl.toString(); + const activeTransport = transport ?? (_hostFetch ? { fetch: _hostFetch } : undefined); _v2BaseUrl = baseUrl; - return createLazyV2Client(baseUrl); + _useSdkTransport = Boolean(activeTransport?.fetch || activeTransport?.headers); + return createLazyV2Client(baseUrl, activeTransport); } export interface StructuredOutputOptions { @@ -86,7 +82,28 @@ export interface StructuredOutputOptions { * or final Zod validation failure. */ export async function generateStructuredOutput(opts: StructuredOutputOptions): Promise { - const { providerID, modelID, systemPrompt, userPrompt, schema, directory, retryCount } = opts; + const { client, providerID, modelID, systemPrompt, userPrompt, schema, directory, retryCount } = + opts; + + const jsonSchema = + ( + schema as unknown as { + toJSONSchema?: () => Record; + } + ).toJSONSchema?.() ?? (await import("zod")).z.toJSONSchema(schema); + + if (_useSdkTransport && hasV2SessionClient(client)) { + return generateViaSdkClient(client, { + providerID, + modelID, + systemPrompt, + userPrompt, + directory, + retryCount, + jsonSchema, + schema, + }); + } const baseUrl = _v2BaseUrl; if (!baseUrl) { @@ -96,16 +113,6 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< } const base = stripTrailingSlash(baseUrl); - // zod v4 exposes JSON Schema export natively (instance `.toJSONSchema()` - // and global `z.toJSONSchema()`); we prefer instance, fall back to global. - // This avoids pulling in a separate `zod-to-json-schema` dependency. - const jsonSchema = - ( - schema as unknown as { - toJSONSchema?: () => Record; - } - ).toJSONSchema?.() ?? (await import("zod")).z.toJSONSchema(schema); - const sessionID = await createSession(base, directory); try { const info = await promptSession(base, { @@ -144,6 +151,112 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< } } +type V2SessionClient = { + session: { + create(parameters?: Record): Promise; + prompt(parameters: Record): Promise; + delete(parameters: Record): Promise; + }; +}; + +interface SdkStructuredOutputArgs { + providerID: string; + modelID: string; + systemPrompt: string; + userPrompt: string; + directory?: string; + retryCount?: number; + jsonSchema: Record; + schema: z.ZodType; +} + +function hasV2SessionClient(client: OpencodeClient): client is OpencodeClient & V2SessionClient { + const session = (client as unknown as { session?: unknown }).session; + if (typeof session !== "object" || session === null) return false; + const candidate = session as Record; + return ( + typeof candidate.create === "function" && + typeof candidate.prompt === "function" && + typeof candidate.delete === "function" + ); +} + +async function generateViaSdkClient( + client: OpencodeClient & V2SessionClient, + args: SdkStructuredOutputArgs +): Promise { + const createdResponse = await client.session.create({ + title: "opencode-mem capture", + ...(args.directory ? { directory: args.directory } : {}), + }); + const created = readSdkData<{ id?: string }>(createdResponse, "POST /session"); + if (!created.id) { + throw new Error( + "opencode-mem: session.create returned no session id; cannot generate structured output" + ); + } + + const sessionID = created.id; + try { + const promptResponse = await client.session.prompt({ + sessionID, + ...(args.directory ? { directory: args.directory } : {}), + model: { providerID: args.providerID, modelID: args.modelID }, + system: args.systemPrompt, + parts: [{ type: "text", text: args.userPrompt }], + format: { + type: "json_schema", + schema: args.jsonSchema, + ...(args.retryCount !== undefined ? { retryCount: args.retryCount } : {}), + }, + }); + const data = readSdkData(promptResponse, "POST /session/{id}/message"); + if (!data.info) { + throw new Error("opencode-mem: prompt response missing `info`"); + } + if (data.info.error) { + throw new Error( + `opencode-mem: opencode reported ${data.info.error.name}: ${formatAssistantError(data.info.error)}` + ); + } + + const structuredOutput = data.info.structured_output ?? data.info.structured; + if (structuredOutput === undefined || structuredOutput === null) { + throw new Error( + "opencode-mem: opencode returned no structured output (info.structured_output/info.structured were empty)" + ); + } + return args.schema.parse(structuredOutput); + } finally { + try { + await client.session.delete({ + sessionID, + ...(args.directory ? { directory: args.directory } : {}), + }); + } catch { + // Best-effort cleanup for the transient capture session. + } + } +} + +function readSdkData(response: unknown, label: string): T { + const result = response as + | { data?: T; error?: unknown; request?: Request; response?: Response } + | undefined; + if (result?.error !== undefined) { + const status = result.response ? ` (${responseStatus(result.response)})` : ""; + const responseUrl = result.response?.url || result.request?.url; + const url = responseUrl ? diagnosticUrl(responseUrl) : "the authenticated client"; + throw new Error( + `opencode-mem: opencode ${label} failed at ${url}${status}: ` + ); + } + if (result?.data === undefined) { + throw new Error(`opencode-mem: opencode ${label} returned no response data`); + } + return result.data; +} + function stripTrailingSlash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } diff --git a/src/services/ai/opencode-sdk-client.ts b/src/services/ai/opencode-sdk-client.ts index d7b0a72..45446bc 100644 --- a/src/services/ai/opencode-sdk-client.ts +++ b/src/services/ai/opencode-sdk-client.ts @@ -2,21 +2,37 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; type CreateOpencodeClient = (config: { readonly baseUrl: string }) => OpencodeClient; +export type HostTransport = { + readonly fetch?: typeof fetch; + readonly headers?: RequestInit["headers"]; +}; + function getOpencodeSdkClientSpecifier(): string { return ["@opencode-ai/sdk", "/v2/client"].join(""); } -async function createSdkClient(baseUrl: string): Promise { +async function createSdkClient( + baseUrl: string, + transport?: HostTransport +): Promise { const sdk = (await import(getOpencodeSdkClientSpecifier())) as { - readonly createOpencodeClient: CreateOpencodeClient; + readonly createOpencodeClient: ( + config: CreateOpencodeClient extends (config: infer T) => OpencodeClient + ? T & { readonly fetch?: typeof fetch; readonly headers?: RequestInit["headers"] } + : never + ) => OpencodeClient; }; - return sdk.createOpencodeClient({ baseUrl }); + return sdk.createOpencodeClient({ + baseUrl, + ...(transport?.fetch ? { fetch: transport.fetch } : {}), + ...(transport?.headers ? { headers: transport.headers } : {}), + }); } -export function createLazyV2Client(baseUrl: string): OpencodeClient { +export function createLazyV2Client(baseUrl: string, transport?: HostTransport): OpencodeClient { let sdkClientPromise: Promise | undefined; const getSdkClient = (): Promise => { - sdkClientPromise ??= createSdkClient(baseUrl); + sdkClientPromise ??= createSdkClient(baseUrl, transport); return sdkClientPromise; }; diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index 7055060..babc713 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -582,15 +582,20 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { calls.push(call); if (call.method === "POST" && call.url.endsWith("/session")) { - return new Response(JSON.stringify({ id: "ses_host_fetch" })); + return new Response(JSON.stringify({ id: "ses_host_fetch" }), { + headers: { "Content-Type": "application/json" }, + }); } if (call.method === "POST" && call.url.includes("/session/ses_host_fetch/message")) { return new Response( - JSON.stringify({ info: { structured_output: { topic: "host", count: 1 } }, parts: [] }) + JSON.stringify({ info: { structured_output: { topic: "host", count: 1 } }, parts: [] }), + { headers: { "Content-Type": "application/json" } } ); } if (call.method === "DELETE") { - return new Response(JSON.stringify(true)); + return new Response(JSON.stringify(true), { + headers: { "Content-Type": "application/json" }, + }); } throw new Error(`unexpected host fetch: ${call.method} ${call.url}`); }, diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index 2fafacd..c821e87 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -44,6 +44,19 @@ describe("OpenCode host client config", () => { }); }); + it("extracts host default headers from nested SDK service clients", () => { + const ctx = pluginInput({ + session: sdkService({ + baseUrl: "http://localhost:4096", + headers: { Authorization: "Basic test-credential" }, + }), + }); + + expect(getHostClientConfig(ctx).headers).toEqual({ + Authorization: "Basic test-credential", + }); + }); + it("resets stale host fetch and logs when SDK config reflection finds no host fetch", async () => { const globalFetch = globalThis.fetch; const logFile = join(mkdtempSync(join(tmpdir(), "opencode-mem-test-")), "opencode-mem.log");