Skip to content

Commit a68dcf0

Browse files
authored
fix: surface actionable error when OpenCode prompt returns no response data (#28)
On opencode CLI 1.17.x an invalid or unavailable provider/model ID (e.g. github-copilot/gpt-5 from #21) no longer produces an empty HTTP body; the prompt call succeeds but result.data is falsy, bypassing the actionable empty-JSON error from #24. - doGenerate/doStream now surface an APICallError naming the model ID, suggesting `opencode models`, including the server error payload when available, with errorType "EmptyResponseData"; the streaming path terminates instead of hanging on the event subscription - wrapError returns already-wrapped AI SDK errors unchanged - managed clients are always created with responseStyle "fields"; session-creation and prompt result handling tolerate data-style results from caller-supplied clients (Codex review follow-ups) - export createEmptyResponseDataError Verified against dist/ with opencode CLI 1.17.3.
1 parent 62ad134 commit a68dcf0

8 files changed

Lines changed: 394 additions & 8 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [3.0.5] - 2026-06-11
99

10+
### Added
11+
12+
- **Exported `createEmptyResponseDataError`** - New error factory alongside the existing `createAPICallError` / `createTimeoutError` exports.
13+
1014
### Fixed
1115

16+
- **Empty response data errors** - Prompt calls that succeed but return no response data (the behavior of opencode CLI 1.17.x for invalid or unavailable `provider/model` IDs, e.g. the `github-copilot/gpt-5` repro from [#21](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk/issues/21)) now throw an actionable `APICallError` that names the requested model ID, suggests checking `opencode models`, includes the server error payload when available, and carries `errorType: "EmptyResponseData"` — replacing the generic `No response data from OpenCode`. The streaming path surfaces the same error as an `error` stream part and terminates the stream instead of hanging on the event subscription.
17+
- **`wrapError` double-wrapping** - `wrapError` now returns already-wrapped AI SDK errors (`APICallError`, `LoadAPIKeyError`) unchanged instead of re-wrapping them and losing their metadata.
1218
- **Singleton client manager recovery after dispose** - `OpencodeClientManager.dispose()` now releases the singleton slot when the disposed manager is the singleton, so a later `createOpencode()` builds a fresh client manager. Previously the singleton kept pointing at the disposed instance, and every subsequent provider in the same process failed with `Client manager has been disposed`.
1319
- **client-options example** - Step 2 of `examples/client-options.ts` now genuinely demonstrates the preconfigured-client pattern by passing an isolated manager via `clientManager: OpencodeClientManager.createInstance({ client })`. Previously the preconfigured client was handed to the singleton that step 1 had already initialized, so it was ignored and step 2's requests flowed through step 1's client while the demo reported success. The example now also echoes each outgoing request's `x-demo-source` header so the output proves which client served it, drops a spurious `await` on the synchronous v2 `createOpencodeClient()`, and allows overriding the example model via `OPENCODE_MODEL`.
1420

1521
### Changed
1622

1723
- **Clearer stale-options warnings** - The client manager's "already initialized" warnings now point at the escape hatches that actually work (`OpencodeClientManager.createInstance()` with the `clientManager` provider setting, or `OpencodeClientManager.resetInstance()`) instead of the previous generic advice.
24+
- **`clientOptions.responseStyle` normalization** - Managed clients are now always created with fields-style SDK results; `clientOptions.responseStyle: "data"` is ignored with a warning since the provider's response handling requires `{ data, error }` results. Session-creation and prompt result handling also tolerate data-style results from caller-supplied (preconfigured) clients.
1825

1926
## [3.0.4] - 2026-06-11
2027

src/errors.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isOutputLengthError,
77
createAuthenticationError,
88
createAPICallError,
9+
createEmptyResponseDataError,
910
createTimeoutError,
1011
extractErrorMessage,
1112
wrapError,
@@ -240,6 +241,45 @@ describe("errors", () => {
240241
});
241242
});
242243

244+
describe("createEmptyResponseDataError", () => {
245+
it("should create actionable error with model guidance", () => {
246+
const result = createEmptyResponseDataError(undefined, {
247+
sessionId: "session-123",
248+
modelId: "github-copilot/gpt-5",
249+
});
250+
251+
expect(result).toBeInstanceOf(APICallError);
252+
expect(result.message).toContain("OpenCode returned no response data");
253+
expect(result.message).toContain('for model "github-copilot/gpt-5"');
254+
expect(result.message).toContain("provider/model");
255+
expect(result.message).toContain("opencode models");
256+
expect(result.isRetryable).toBe(false);
257+
expect(result.data).toMatchObject({
258+
errorType: "EmptyResponseData",
259+
sessionId: "session-123",
260+
modelId: "github-copilot/gpt-5",
261+
});
262+
});
263+
264+
it("should omit model hint when modelId is missing", () => {
265+
const result = createEmptyResponseDataError(undefined);
266+
267+
expect(result.message).toContain("OpenCode returned no response data");
268+
expect(result.message).not.toContain("for model");
269+
expect(result.message).not.toContain("Original error");
270+
});
271+
272+
it("should include original error details when available", () => {
273+
const result = createEmptyResponseDataError(
274+
{ message: "model not found", statusCode: 400 },
275+
{ modelId: "github-copilot/gpt-5" },
276+
);
277+
278+
expect(result.message).toContain("Original error: model not found");
279+
expect(result.statusCode).toBe(400);
280+
});
281+
});
282+
243283
describe("createTimeoutError", () => {
244284
it("should create timeout error with duration", () => {
245285
const result = createTimeoutError(5000);
@@ -370,5 +410,19 @@ describe("errors", () => {
370410
sessionId: "session-123",
371411
});
372412
});
413+
414+
it("should return already-wrapped AI SDK errors unchanged", () => {
415+
const original = createEmptyResponseDataError(undefined, {
416+
sessionId: "session-123",
417+
modelId: "github-copilot/gpt-5",
418+
});
419+
const result = wrapError(original, { sessionId: "other-session" });
420+
421+
expect(result).toBe(original);
422+
expect((result as APICallError).data).toMatchObject({
423+
errorType: "EmptyResponseData",
424+
sessionId: "session-123",
425+
});
426+
});
373427
});
374428
});

src/errors.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,36 @@ function createEmptyResponseError(
209209
});
210210
}
211211

212+
/**
213+
* Create an actionable error for OpenCode responses that complete without
214+
* any response data.
215+
*/
216+
export function createEmptyResponseDataError(
217+
error: unknown,
218+
metadata?: Partial<OpencodeErrorMetadata>,
219+
): APICallError {
220+
const modelHint = metadata?.modelId ? ` for model "${metadata.modelId}"` : "";
221+
const detail = error ? ` Original error: ${extractErrorMessage(error)}` : "";
222+
223+
return new APICallError({
224+
message:
225+
`OpenCode returned no response data${modelHint}. ` +
226+
"This usually means the configured provider/model ID is invalid or unavailable. " +
227+
"Run `opencode models` to list available model IDs and check the OpenCode provider configuration." +
228+
detail,
229+
url: "opencode://session",
230+
requestBodyValues: {},
231+
statusCode: metadata?.statusCode ?? extractStatusCode(error),
232+
isRetryable: false,
233+
data: {
234+
errorType: "EmptyResponseData",
235+
sessionId: metadata?.sessionId,
236+
messageId: metadata?.messageId,
237+
modelId: metadata?.modelId,
238+
},
239+
});
240+
}
241+
212242
/**
213243
* Create a timeout error.
214244
*/
@@ -375,6 +405,11 @@ export function wrapError(
375405
error: unknown,
376406
metadata?: Partial<OpencodeErrorMetadata>,
377407
): Error {
408+
// Errors created by this module are already actionable AI SDK errors.
409+
if (APICallError.isInstance(error) || LoadAPIKeyError.isInstance(error)) {
410+
return error;
411+
}
412+
378413
if (isAuthenticationError(error)) {
379414
return createAuthenticationError(error);
380415
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export {
5454
isOutputLengthError,
5555
createAuthenticationError,
5656
createAPICallError,
57+
createEmptyResponseDataError,
5758
createTimeoutError,
5859
extractErrorMessage,
5960
wrapError,

src/opencode-client-manager.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,33 @@ describe("opencode-client-manager", () => {
341341
);
342342
});
343343

344+
it('should force fields responseStyle and warn when clientOptions requests "data"', async () => {
345+
const warn = vi.fn();
346+
347+
const instance = OpencodeClientManager.getInstance({
348+
baseUrl: "http://custom-server:8080",
349+
clientOptions: {
350+
responseStyle: "data",
351+
},
352+
logger: {
353+
warn,
354+
error: vi.fn(),
355+
},
356+
});
357+
358+
await instance.getClient();
359+
360+
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
361+
expect(createOpencodeClient).toHaveBeenCalledWith(
362+
expect.objectContaining({
363+
responseStyle: "fields",
364+
}),
365+
);
366+
expect(warn).toHaveBeenCalledWith(
367+
expect.stringContaining("Ignoring clientOptions.responseStyle"),
368+
);
369+
});
370+
344371
it("should not warn for undefined reserved keys in clientOptions", async () => {
345372
const warn = vi.fn();
346373

src/opencode-client-manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,17 @@ export class OpencodeClientManager {
287287
"Ignoring clientOptions.directory because directory is managed by model settings/defaultSettings.",
288288
);
289289
}
290+
if (optionsRecord.responseStyle === "data") {
291+
this.logger.warn(
292+
'Ignoring clientOptions.responseStyle "data" because the provider requires fields-style SDK results.',
293+
);
294+
}
290295

291296
return {
292297
...options,
293298
baseUrl,
294299
directory: this.options.cwd,
300+
responseStyle: "fields",
295301
};
296302
}
297303

0 commit comments

Comments
 (0)