Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
);
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/services/ai/opencode-host-config.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -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)
);
}
161 changes: 137 additions & 24 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string> = 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;
Expand All @@ -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<T> {
Expand All @@ -86,7 +82,28 @@ export interface StructuredOutputOptions<T> {
* or final Zod validation failure.
*/
export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<T>): Promise<T> {
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<string, unknown>;
}
).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) {
Expand All @@ -96,16 +113,6 @@ export async function generateStructuredOutput<T>(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<string, unknown>;
}
).toJSONSchema?.() ?? (await import("zod")).z.toJSONSchema(schema);

const sessionID = await createSession(base, directory);
try {
const info = await promptSession(base, {
Expand Down Expand Up @@ -144,6 +151,112 @@ export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<
}
}

type V2SessionClient = {
session: {
create(parameters?: Record<string, unknown>): Promise<unknown>;
prompt(parameters: Record<string, unknown>): Promise<unknown>;
delete(parameters: Record<string, unknown>): Promise<unknown>;
};
};

interface SdkStructuredOutputArgs<T> {
providerID: string;
modelID: string;
systemPrompt: string;
userPrompt: string;
directory?: string;
retryCount?: number;
jsonSchema: Record<string, unknown>;
schema: z.ZodType<T>;
}

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<string, unknown>;
return (
typeof candidate.create === "function" &&
typeof candidate.prompt === "function" &&
typeof candidate.delete === "function"
);
}

async function generateViaSdkClient<T>(
client: OpencodeClient & V2SessionClient,
args: SdkStructuredOutputArgs<T>
): Promise<T> {
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<MessageV2WithParts>(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<T>(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}: <redacted response body>`
);
}
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;
}
Expand Down
26 changes: 21 additions & 5 deletions src/services/ai/opencode-sdk-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpencodeClient> {
async function createSdkClient(
baseUrl: string,
transport?: HostTransport
): Promise<OpencodeClient> {
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<OpencodeClient> | undefined;
const getSdkClient = (): Promise<OpencodeClient> => {
sdkClientPromise ??= createSdkClient(baseUrl);
sdkClientPromise ??= createSdkClient(baseUrl, transport);
return sdkClientPromise;
};

Expand Down
11 changes: 8 additions & 3 deletions tests/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
},
Expand Down
13 changes: 13 additions & 0 deletions tests/plugin-host-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down