diff --git a/.changeset/clean-poolside-auth.md b/.changeset/clean-poolside-auth.md new file mode 100644 index 0000000..0de9e21 --- /dev/null +++ b/.changeset/clean-poolside-auth.md @@ -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. diff --git a/README.md b/README.md index 26eb062..73cc355 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/RELEASING.md b/RELEASING.md index 77d2d02..abd0f96 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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` @@ -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` 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` release without republishing. diff --git a/opencode.json b/opencode.json index dd8e079..a28ac20 100644 --- a/opencode.json +++ b/opencode.json @@ -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", diff --git a/package-lock.json b/package-lock.json index d381d62..9c086be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-provider-poolside", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-provider-poolside", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@ai-sdk/openai-compatible": "^1.0.0", diff --git a/package.json b/package.json index 5cf1d29..c370405 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/plugin.ts b/plugin.ts index 2f79aac..b55c1ae 100644 --- a/plugin.ts +++ b/plugin.ts @@ -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, @@ -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", @@ -39,7 +39,7 @@ import type { OpenCodeProviderConfig } from "./src/types.ts"; */ export default async function poolsidePlugin( _input: PluginInput -) { +): Promise { return { /** * Config hook: registers the Poolside provider and discovers models. @@ -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): Promise => { - const providerConfig = ensureProviderConfig(config, PROVIDER_ID); + config: async (config): Promise => { + const providerConfig = ensureProviderConfig( + config as Record, + PROVIDER_ID + ); // Set provider metadata if not already configured. if (!providerConfig.npm) { @@ -111,20 +114,9 @@ export default async function poolsidePlugin( { type: "api" as const, label: "API Key", - authorize: async ( - inputs: Record | 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> => { + loader: async (getAuth): Promise> => { try { const auth = await getAuth(); if (!auth) return {}; diff --git a/src/constants.ts b/src/constants.ts index 5521db3..55a6d39 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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; /** @@ -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; /** diff --git a/src/models.ts b/src/models.ts index 03fdddb..801f9ff 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,6 +2,7 @@ import { FALLBACK_MODELS, MAX_ONLY_VARIANTS, POOLSIDE_BASE_URL, + PROVIDER_ID, REASONING_VARIANTS, } from "./constants.ts"; import type { @@ -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 ), }, @@ -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 `/` 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 { const map: Record = {}; 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; } diff --git a/src/types.ts b/src/types.ts index a7164ad..bc50854 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -50,7 +51,7 @@ export type OpenCodeModelConfig = { context: number; output: number; }; - variants?: Record; + variants?: Record>; modalities?: { input: string[]; output: string[]; diff --git a/test/models.test.ts b/test/models.test.ts index 230051d..c23885d 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -110,6 +110,7 @@ describe("parseModelsResponse", () => { prompt: "0.14", completion: "0.28", input_cache_read: "0.01", + input_cache_write: "0.02", }, }, ], @@ -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", () => { @@ -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", () => { diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..352ebe7 --- /dev/null +++ b/test/package.test.ts @@ -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"))); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index cfff3de..5083f65 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -9,7 +9,7 @@ type PluginResult = { methods: Array<{ type: string; label: string; - authorize: ( + authorize?: ( inputs: Record | undefined ) => Promise<{ type: string; key?: string }>; }>; @@ -83,40 +83,11 @@ describe("Poolside OpenCode Plugin", () => { assert.equal(plugin.auth.provider, "poolside"); }); - test("authorize returns success with valid key", async () => { + test("uses OpenCode's built-in API key prompt and storage", async () => { const plugin = await pluginFn(); - const result = await plugin.auth.methods[0].authorize({ key: "sk-valid-key" }); - assert.equal(result.type, "success"); - assert.equal( - (result as { type: string; key: string }).key, - "sk-valid-key" - ); - }); - - test("authorize returns failed with empty key", async () => { - const plugin = await pluginFn(); - const result = await plugin.auth.methods[0].authorize({ key: " " }); - assert.equal(result.type, "failed"); - }); - - test("authorize returns failed with undefined key", async () => { - const plugin = await pluginFn(); - const result = await plugin.auth.methods[0].authorize({ key: undefined }); - assert.equal(result.type, "failed"); - }); - - test("authorize returns failed with missing inputs", async () => { - const plugin = await pluginFn(); - const result = await plugin.auth.methods[0].authorize(undefined); - assert.equal(result.type, "failed"); - }); - - test("authorize returns failed with non-string key", async () => { - const plugin = await pluginFn(); - const result = await plugin.auth.methods[0].authorize({ - key: 123 as unknown as string, - }); - assert.equal(result.type, "failed"); + assert.equal(plugin.auth.methods[0].type, "api"); + assert.equal(plugin.auth.methods[0].label, "API Key"); + assert.equal(plugin.auth.methods[0].authorize, undefined); }); test("loader returns apiKey on successful auth", async () => { @@ -195,9 +166,9 @@ describe("Poolside OpenCode Plugin", () => { const models = poolside.models as Record; assert.ok(models); assert.ok(Object.keys(models).length > 0); - assert.ok(models["poolside/laguna-m.1"]); - assert.ok(models["poolside/laguna-xs-2.1"]); - assert.ok(models["poolside/laguna-s-2.1"]); + assert.ok(models["laguna-m.1"]); + assert.ok(models["laguna-xs-2.1"]); + assert.ok(models["laguna-s-2.1"]); }); test("config hook does not overwrite existing npm field", async () => { @@ -227,7 +198,7 @@ describe("Poolside OpenCode Plugin", () => { ).poolside; const models = poolside.models as Record; assert.ok(models["my-model"]); - assert.ok(models["poolside/laguna-m.1"]); + assert.ok(models["laguna-m.1"]); }); test("config hook creates provider block if missing", async () => { @@ -293,8 +264,8 @@ describe("Poolside OpenCode Plugin", () => { config.provider as Record> ).poolside; const models = poolside.models as Record; - assert.ok(models["poolside/laguna-m.1"]); - assert.ok(models["poolside/laguna-xs-2.1"]); + assert.ok(models["laguna-m.1"]); + assert.ok(models["laguna-xs-2.1"]); } finally { if (originalEnv !== undefined) { process.env.POOLSIDE_API_KEY = originalEnv; @@ -321,7 +292,7 @@ describe("Poolside OpenCode Plugin", () => { config.provider as Record> ).poolside; const models = poolside.models as Record; - assert.ok(models["poolside/laguna-m.1"]); + assert.ok(models["laguna-m.1"]); } finally { if (originalEnv !== undefined) { process.env.POOLSIDE_API_KEY = originalEnv; @@ -350,7 +321,7 @@ describe("Poolside OpenCode Plugin", () => { config.provider as Record> ).poolside; const models = poolside.models as Record; - assert.ok(models["poolside/laguna-m.1"]); + assert.ok(models["laguna-m.1"]); } finally { if (originalEnv !== undefined) { process.env.POOLSIDE_API_KEY = originalEnv; @@ -399,9 +370,9 @@ describe("Poolside OpenCode Plugin", () => { ).poolside; const models = poolside.models as Record; assert.ok(models["my-custom-model"]); - assert.ok(models["poolside/laguna-m.1"]); + assert.ok(models["laguna-m.1"]); // Discovered model should have full metadata including modalities - const discovered = models["poolside/laguna-m.1"] as Record; + const discovered = models["laguna-m.1"] as Record; assert.ok(discovered.modalities); } finally { if (originalEnv !== undefined) { @@ -444,7 +415,7 @@ describe("Poolside OpenCode Plugin", () => { config.provider as Record> ).poolside; const models = poolside.models as Record; - const model = models["poolside/laguna-m.1"] as Record; + const model = models["laguna-m.1"] as Record; assert.ok(model.modalities); const modalities = model.modalities as Record; assert.deepEqual(modalities.input, ["text"]); @@ -489,11 +460,11 @@ describe("Poolside OpenCode Plugin", () => { config.provider as Record> ).poolside; const models = poolside.models as Record; - const model = models["poolside/laguna-s-2.1"] as Record; + const model = models["laguna-s-2.1"] as Record; const variants = model.variants as Record; assert.ok(variants); - assert.ok(variants.none); - assert.ok(variants.xhigh); + assert.deepEqual(variants.none, { reasoning: { effort: "none" } }); + assert.deepEqual(variants.xhigh, { reasoning: { effort: "xhigh" } }); assert.ok(!variants.minimal); assert.ok(!variants.low); assert.ok(!variants.medium);