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
5 changes: 5 additions & 0 deletions .changeset/clean-poolside-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-provider-poolside": patch
---

Fix the default plugin entry point, OpenCode API-key login, model aliases, and Poolside reasoning request options. Package metadata and release checks now keep published artifacts consistent.
20 changes: 6 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,17 @@

### 1. Install the plugin

```bash
npm install -g opencode-provider-poolside
```

Or add it directly to your OpenCode config:
Add the package to your OpenCode config. OpenCode installs it automatically:

```jsonc
// ~/.config/opencode/opencode.json or .opencode/opencode.json
{
"plugin": ["opencode-provider-poolside/server"],
"provider": {
"poolside": {
"npm": "@ai-sdk/openai-compatible",
"name": "Poolside",
"env": ["POOLSIDE_API_KEY"]
}
}
"plugin": ["opencode-provider-poolside"]
}
```

The legacy `opencode-provider-poolside/server` entry point remains supported.

### 2. Get a Poolside API key

Create a developer API key at [platform.poolside.ai](https://platform.poolside.ai) → API Keys → New key.
Expand Down Expand Up @@ -135,7 +126,8 @@ export POOLSIDE_API_KEY="your-api-key"

1. **Config hook** — On startup, the plugin registers the `poolside` provider with `@ai-sdk/openai-compatible`, sets the base URL and environment variable, and populates the model catalog.
2. **Model discovery** — If `POOLSIDE_API_KEY` is available, the plugin fetches the live model list from `https://inference.poolside.ai/v1/models`. If the API is unreachable, it falls back to a static catalog of known Laguna models.
3. **Auth hook** — The plugin provides an API key auth method so you can manage your key with OpenCode's `/connect poolside` command.
3. **Model aliases** — OpenCode exposes models as `poolside/laguna-*` while sending Poolside's required `poolside/laguna-*` upstream IDs without duplicating the provider prefix.
4. **Auth hook** — The plugin provides an API key auth method so you can manage your key with OpenCode's `/connect poolside` command.

## Related projects

Expand Down
8 changes: 3 additions & 5 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

Releases are managed by Changesets and `.github/workflows/release.yml`.

## One-time npm setup
## npm trusted publishing

If `opencode-provider-poolside` does not exist on npm yet, an owner must bootstrap `0.1.0` once from a trusted local checkout with `npm publish --access public`. A trusted publisher is configured from an existing package's npm settings.

Then configure the package's npm trusted publisher with:
The package has already been bootstrapped on npm. Its trusted publisher must remain configured with:

- Organization or user: `grikomsn`
- Repository: `opencode-provider-poolside`
Expand All @@ -24,4 +22,4 @@ No long-lived `NPM_TOKEN` is used by GitHub Actions. The release job runs on a G
4. The release workflow validates the package and runs `npm publish --access public --provenance` through npm trusted publishing.
5. The workflow creates the matching `v<version>` GitHub release.

After the bootstrap publish and trusted-publisher setup, run the release workflow once to create the matching `v0.1.0` GitHub release. Subsequent releases should go through the Changesets version pull request.
If npm publication succeeds but the workflow fails before creating the GitHub release, rerun the release workflow. Its version checks skip the existing npm version and create the missing `v<version>` release without republishing.
2 changes: 1 addition & 1 deletion opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://opencode.ai/config.json",
"model": "poolside/laguna-s-2.1",
"small_model": "poolside/laguna-xs-2.1",
"plugin": ["opencode-provider-poolside/server"],
"plugin": ["opencode-provider-poolside"],
"provider": {
"poolside": {
"npm": "@ai-sdk/openai-compatible",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,31 @@
"plugin.ts",
"index.ts",
"src/",
"assets/",
"assets/cover.jpg",
"opencode.json",
"CHANGELOG.md",
"README.md",
"LICENSE"
],
"exports": {
".": {
"import": "./index.ts"
"import": "./plugin.ts"
},
"./server": {
"import": "./plugin.ts"
},
"./library": {
"import": "./index.ts"
}
},
"main": "./index.ts",
"main": "./plugin.ts",
"scripts": {
"test": "node --experimental-strip-types --test test/*.test.ts",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm test",
"package": "npm pack --dry-run",
"changeset": "changeset",
"version": "changeset version"
"version": "changeset version && npm install --package-lock-only --ignore-scripts"
},
"peerDependencies": {
"@ai-sdk/openai-compatible": ">=1.0.0"
Expand Down
26 changes: 9 additions & 17 deletions plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin";
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
import {
FALLBACK_MODELS,
POOLSIDE_API_KEY_ENV,
Expand Down Expand Up @@ -26,7 +26,7 @@ import type { OpenCodeProviderConfig } from "./src/types.ts";
* Usage in `opencode.json`:
* ```json
* {
* "plugin": ["opencode-provider-poolside/server"],
* "plugin": ["opencode-provider-poolside"],
* "provider": {
* "poolside": {
* "npm": "@ai-sdk/openai-compatible",
Expand All @@ -39,7 +39,7 @@ import type { OpenCodeProviderConfig } from "./src/types.ts";
*/
export default async function poolsidePlugin(
_input: PluginInput
) {
): Promise<Hooks> {
return {
/**
* Config hook: registers the Poolside provider and discovers models.
Expand All @@ -48,8 +48,11 @@ export default async function poolsidePlugin(
* The hook mutates the config in place to add or update the `poolside`
* provider entry.
*/
config: async (config: Record<string, unknown>): Promise<void> => {
const providerConfig = ensureProviderConfig(config, PROVIDER_ID);
config: async (config): Promise<void> => {
const providerConfig = ensureProviderConfig(
config as Record<string, unknown>,
PROVIDER_ID
);

// Set provider metadata if not already configured.
if (!providerConfig.npm) {
Expand Down Expand Up @@ -111,20 +114,9 @@ export default async function poolsidePlugin(
{
type: "api" as const,
label: "API Key",
authorize: async (
inputs: Record<string, unknown> | undefined
): Promise<{ type: "success"; key: string } | { type: "failed" }> => {
const rawKey = inputs?.key;
if (typeof rawKey !== "string") return { type: "failed" };
const key = rawKey.trim();
if (!key) return { type: "failed" };
return { type: "success", key };
},
},
],
loader: async (
getAuth: () => Promise<{ type: string; key?: string } | null>
): Promise<Record<string, unknown>> => {
loader: async (getAuth): Promise<Record<string, unknown>> => {
try {
const auth = await getAuth();
if (!auth) return {};
Expand Down
22 changes: 10 additions & 12 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,16 @@ export const MAX_ONLY_THINKING_LEVELS = {
/**
* Reasoning effort variants injected into OpenCode model config.
*
* Each entry maps an OpenCode reasoning-effort level to the
* `reasoningEffort` value that OpenCode sends to the provider. The
* `reasoning` field is an OpenRouter-style extension that Poolside
* accepts on its OpenAI-compatible API.
* Each entry maps an OpenCode variant to the OpenRouter-style `reasoning`
* object accepted by Poolside's OpenAI-compatible API.
*/
export const REASONING_VARIANTS = {
none: { reasoningEffort: "none" },
minimal: { reasoningEffort: "minimal" },
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
none: { reasoning: { effort: "none" } },
minimal: { reasoning: { effort: "minimal" } },
low: { reasoning: { effort: "low" } },
medium: { reasoning: { effort: "medium" } },
high: { reasoning: { effort: "high" } },
xhigh: { reasoning: { effort: "xhigh" } },
} as const;

/**
Expand All @@ -82,8 +80,8 @@ export const REASONING_VARIANTS = {
* OpenCode variant picker doesn't offer unsupported levels.
*/
export const MAX_ONLY_VARIANTS = {
none: { reasoningEffort: "none" },
xhigh: { reasoningEffort: "xhigh" },
none: { reasoning: { effort: "none" } },
xhigh: { reasoning: { effort: "xhigh" } },
} as const;

/**
Expand Down
14 changes: 11 additions & 3 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
FALLBACK_MODELS,
MAX_ONLY_VARIANTS,
POOLSIDE_BASE_URL,
PROVIDER_ID,
REASONING_VARIANTS,
} from "./constants.ts";
import type {
Expand Down Expand Up @@ -90,7 +91,7 @@ export function parseModelsResponse(
fallback?.cost.cache_read ?? 0
),
cache_write: nonNegativeNumber(
pricing?.input_cache_read,
pricing?.input_cache_write,
fallback?.cost.cache_write ?? 0
),
},
Expand Down Expand Up @@ -150,14 +151,21 @@ export async function fetchModels(
/**
* Convert an array of model configs into the OpenCode provider `models` map.
*
* The map key is the model ID (e.g. `poolside/laguna-m.1`).
* OpenCode addresses a model as `<provider>/<config key>` but sends the
* config entry's `id` to the upstream API. Poolside's upstream IDs already
* start with `poolside/`, so using the full ID as the key would expose the
* incorrect doubled name `poolside/poolside/laguna-m.1`.
*/
export function modelsToConfigMap(
models: OpenCodeModelConfig[]
): Record<string, OpenCodeModelConfig> {
const map: Record<string, OpenCodeModelConfig> = {};
for (const model of models) {
map[model.id] = model;
const prefix = `${PROVIDER_ID}/`;
const key = model.id.startsWith(prefix)
? model.id.slice(prefix.length)
: model.id;
map[key] = model;
}
return map;
}
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type PoolsideApiModel = {
image?: unknown;
request?: unknown;
input_cache_read?: unknown;
input_cache_write?: unknown;
};
supported_features?: unknown;
supported_sampling_parameters?: unknown;
Expand Down Expand Up @@ -50,7 +51,7 @@ export type OpenCodeModelConfig = {
context: number;
output: number;
};
variants?: Record<string, { reasoningEffort: string }>;
variants?: Record<string, Record<string, unknown>>;
modalities?: {
input: string[];
output: string[];
Expand Down
17 changes: 14 additions & 3 deletions test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe("parseModelsResponse", () => {
prompt: "0.14",
completion: "0.28",
input_cache_read: "0.01",
input_cache_write: "0.02",
},
},
],
Expand All @@ -118,6 +119,7 @@ describe("parseModelsResponse", () => {
assert.equal(result[0]!.cost.input, 0.14);
assert.equal(result[0]!.cost.output, 0.28);
assert.equal(result[0]!.cost.cache_read, 0.01);
assert.equal(result[0]!.cost.cache_write, 0.02);
});

test("sets reasoning to false when not in supported_features", () => {
Expand Down Expand Up @@ -193,15 +195,24 @@ describe("parseModelsResponse", () => {
});

describe("modelsToConfigMap", () => {
test("converts model array to id-keyed map", () => {
test("uses unprefixed OpenCode keys while preserving upstream IDs", () => {
const models = [
{ id: "poolside/laguna-m.1", name: "Laguna M.1" },
{ id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" },
];
const map = modelsToConfigMap(models as never);
assert.equal(Object.keys(map).length, 2);
assert.equal(map["poolside/laguna-m.1"].name, "Laguna M.1");
assert.equal(map["poolside/laguna-xs-2.1"].name, "Laguna XS 2.1");
assert.equal(map["laguna-m.1"].name, "Laguna M.1");
assert.equal(map["laguna-m.1"].id, "poolside/laguna-m.1");
assert.equal(map["laguna-xs-2.1"].name, "Laguna XS 2.1");
assert.ok(!map["poolside/laguna-m.1"]);
});

test("preserves IDs that do not use the provider prefix", () => {
const map = modelsToConfigMap([
{ id: "custom-model", name: "Custom" },
] as never);
assert.equal(map["custom-model"].id, "custom-model");
});

test("returns empty object for empty array", () => {
Expand Down
42 changes: 42 additions & 0 deletions test/package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import test from "node:test";

const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const root = new URL("..", import.meta.url);

test("package metadata and lockfile versions stay synchronized", () => {
const manifest = JSON.parse(readFileSync(new URL("package.json", root), "utf8"));
const lockfile = JSON.parse(readFileSync(new URL("package-lock.json", root), "utf8"));

assert.equal(lockfile.version, manifest.version);
assert.equal(lockfile.packages[""].version, manifest.version);
assert.equal(manifest.exports["."].import, "./plugin.ts");
assert.equal(manifest.exports["./server"].import, "./plugin.ts");
assert.equal(manifest.exports["./library"].import, "./index.ts");
});

test("npm package contains the plugin and intended metadata only", () => {
const output = execFileSync(
npm,
["pack", "--dry-run", "--json", "--ignore-scripts"],
{ cwd: root, encoding: "utf8" }
);
const [{ files }] = JSON.parse(output) as [{ files: Array<{ path: string }> }];
const paths = files.map((file) => file.path);

for (const required of [
"plugin.ts",
"index.ts",
"CHANGELOG.md",
"opencode.json",
"assets/cover.jpg",
]) {
assert.ok(paths.includes(required), `missing ${required}`);
}
assert.ok(!paths.includes("assets/icon.png"));
assert.ok(!paths.some((path) => path.startsWith("test/")));
assert.ok(!paths.some((path) => path.startsWith(".github/")));
assert.ok(!paths.some((path) => path.startsWith(".env")));
});
Loading