Skip to content
Merged
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: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [3.0.5] - 2026-06-11

### Added

- **Exported `createEmptyResponseDataError`** - New error factory alongside the existing `createAPICallError` / `createTimeoutError` exports.

### Fixed

- **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.
- **`wrapError` double-wrapping** - `wrapError` now returns already-wrapped AI SDK errors (`APICallError`, `LoadAPIKeyError`) unchanged instead of re-wrapping them and losing their metadata.
- **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`.
- **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`.

### Changed

- **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.
- **`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.

## [3.0.4] - 2026-06-11

Expand Down
54 changes: 54 additions & 0 deletions src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isOutputLengthError,
createAuthenticationError,
createAPICallError,
createEmptyResponseDataError,
createTimeoutError,
extractErrorMessage,
wrapError,
Expand Down Expand Up @@ -240,6 +241,45 @@ describe("errors", () => {
});
});

describe("createEmptyResponseDataError", () => {
it("should create actionable error with model guidance", () => {
const result = createEmptyResponseDataError(undefined, {
sessionId: "session-123",
modelId: "github-copilot/gpt-5",
});

expect(result).toBeInstanceOf(APICallError);
expect(result.message).toContain("OpenCode returned no response data");
expect(result.message).toContain('for model "github-copilot/gpt-5"');
expect(result.message).toContain("provider/model");
expect(result.message).toContain("opencode models");
expect(result.isRetryable).toBe(false);
expect(result.data).toMatchObject({
errorType: "EmptyResponseData",
sessionId: "session-123",
modelId: "github-copilot/gpt-5",
});
});

it("should omit model hint when modelId is missing", () => {
const result = createEmptyResponseDataError(undefined);

expect(result.message).toContain("OpenCode returned no response data");
expect(result.message).not.toContain("for model");
expect(result.message).not.toContain("Original error");
});

it("should include original error details when available", () => {
const result = createEmptyResponseDataError(
{ message: "model not found", statusCode: 400 },
{ modelId: "github-copilot/gpt-5" },
);

expect(result.message).toContain("Original error: model not found");
expect(result.statusCode).toBe(400);
});
});

describe("createTimeoutError", () => {
it("should create timeout error with duration", () => {
const result = createTimeoutError(5000);
Expand Down Expand Up @@ -370,5 +410,19 @@ describe("errors", () => {
sessionId: "session-123",
});
});

it("should return already-wrapped AI SDK errors unchanged", () => {
const original = createEmptyResponseDataError(undefined, {
sessionId: "session-123",
modelId: "github-copilot/gpt-5",
});
const result = wrapError(original, { sessionId: "other-session" });

expect(result).toBe(original);
expect((result as APICallError).data).toMatchObject({
errorType: "EmptyResponseData",
sessionId: "session-123",
});
});
});
});
35 changes: 35 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,36 @@ function createEmptyResponseError(
});
}

/**
* Create an actionable error for OpenCode responses that complete without
* any response data.
*/
export function createEmptyResponseDataError(
error: unknown,
metadata?: Partial<OpencodeErrorMetadata>,
): APICallError {
const modelHint = metadata?.modelId ? ` for model "${metadata.modelId}"` : "";
const detail = error ? ` Original error: ${extractErrorMessage(error)}` : "";

return new APICallError({
message:
`OpenCode returned no response data${modelHint}. ` +
"This usually means the configured provider/model ID is invalid or unavailable. " +
"Run `opencode models` to list available model IDs and check the OpenCode provider configuration." +
detail,
url: "opencode://session",
requestBodyValues: {},
statusCode: metadata?.statusCode ?? extractStatusCode(error),
isRetryable: false,
data: {
errorType: "EmptyResponseData",
sessionId: metadata?.sessionId,
messageId: metadata?.messageId,
modelId: metadata?.modelId,
},
});
}

/**
* Create a timeout error.
*/
Expand Down Expand Up @@ -375,6 +405,11 @@ export function wrapError(
error: unknown,
metadata?: Partial<OpencodeErrorMetadata>,
): Error {
// Errors created by this module are already actionable AI SDK errors.
if (APICallError.isInstance(error) || LoadAPIKeyError.isInstance(error)) {
return error;
}

if (isAuthenticationError(error)) {
return createAuthenticationError(error);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
isOutputLengthError,
createAuthenticationError,
createAPICallError,
createEmptyResponseDataError,
createTimeoutError,
extractErrorMessage,
wrapError,
Expand Down
27 changes: 27 additions & 0 deletions src/opencode-client-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,33 @@ describe("opencode-client-manager", () => {
);
});

it('should force fields responseStyle and warn when clientOptions requests "data"', async () => {
const warn = vi.fn();

const instance = OpencodeClientManager.getInstance({
baseUrl: "http://custom-server:8080",
clientOptions: {
responseStyle: "data",
},
logger: {
warn,
error: vi.fn(),
},
});

await instance.getClient();

const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
expect(createOpencodeClient).toHaveBeenCalledWith(
expect.objectContaining({
responseStyle: "fields",
}),
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Ignoring clientOptions.responseStyle"),
);
});

it("should not warn for undefined reserved keys in clientOptions", async () => {
const warn = vi.fn();

Expand Down
6 changes: 6 additions & 0 deletions src/opencode-client-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,17 @@ export class OpencodeClientManager {
"Ignoring clientOptions.directory because directory is managed by model settings/defaultSettings.",
);
}
if (optionsRecord.responseStyle === "data") {
this.logger.warn(
'Ignoring clientOptions.responseStyle "data" because the provider requires fields-style SDK results.',
);
}

return {
...options,
baseUrl,
directory: this.options.cwd,
responseStyle: "fields",
};
}

Expand Down
Loading
Loading