diff --git a/.agents/skills/changeset-versioning/SKILL.md b/.agents/skills/changeset-versioning/SKILL.md new file mode 100644 index 0000000..957f038 --- /dev/null +++ b/.agents/skills/changeset-versioning/SKILL.md @@ -0,0 +1,97 @@ +# Changeset Versioning & Release Workflow + +## Overview + +This repo uses [changesets](https://github.com/changesets/changesets) for version management and changelog generation. Publishing is **release-gated** via `workflow_dispatch` — it does NOT auto-publish on push to main. + +## Why Release-Gated? + +`@openrouter/agent` depends on `@openrouter/sdk`. When Speakeasy regenerates SDK types, the agent must be updated to match. Both packages need **coordinated releases** — publishing one without the other can break consumers. + +## Adding a Changeset + +When you make a change that should be included in the next release: + +```bash +pnpm changeset add +``` + +This will prompt you to: +1. Select the package (`@openrouter/agent`) +2. Choose a bump type (`patch`, `minor`, `major`) +3. Write a summary of the change + +This creates a markdown file in `.changeset/` that describes the change. Commit this file with your PR. + +### Bump Type Guidelines + +- **patch** — Bug fixes, type fixes, defensive coding improvements +- **minor** — New features, new exports, new tool capabilities +- **major** — Breaking API changes (e.g., changing `callModel` signature, removing exports) + +## Release Flow + +### Step 1: Version (creates a Version Packages PR) + +Go to **Actions → Release → Run workflow** and select: +- **Mode:** `version` +- **Dry run:** unchecked + +This runs `changesets/action` which: +- Consumes all pending `.changeset/*.md` files +- Bumps `package.json` version +- Updates `CHANGELOG.md` +- Opens a "chore: version packages" PR + +Review and merge that PR to main. + +### Step 2: Publish (pushes to npm) + +After the Version Packages PR is merged, go to **Actions → Release → Run workflow** and select: +- **Mode:** `publish` +- **Dry run:** unchecked (or check it first to verify) + +This runs `pnpm publish --provenance --access public` to push the new version to npm. + +### Dry Run + +To verify what would happen without making changes: +- **Mode:** `publish` +- **Dry run:** checked + +## Coordination with @openrouter/sdk + +Before releasing `@openrouter/agent`: +1. Ensure `@openrouter/sdk` has been published with any required type changes +2. Update the SDK dependency in `package.json` if needed +3. Run `pnpm install` to update the lockfile +4. Verify `pnpm run typecheck` and `pnpm run test` pass +5. Then proceed with the release flow above + +## Changelog + +Changelogs are auto-generated by `@changesets/changelog-github` and include: +- PR links +- Contributor attribution +- Commit references + +The changelog is written to `CHANGELOG.md` during the version step. + +## Configuration + +- `.changeset/config.json` — Changesets configuration +- `.github/workflows/publish.yaml` — Release workflow (workflow_dispatch) +- `.github/workflows/ci.yaml` — PR validation (lint, typecheck, test) + +## Common Commands + +```bash +pnpm changeset add # Add a new changeset +pnpm changeset status # View pending changesets +pnpm changeset version # Apply changesets locally (usually done by CI) +pnpm run build # Build (tsc) +pnpm run typecheck # Type check without emitting +pnpm run test # Run unit tests +pnpm run test:e2e # Run e2e tests (requires OPENROUTER_API_KEY) +pnpm lint # Lint with eslint +``` diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 700037b..e0d8d17 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,8 +1,30 @@ name: Release on: - push: - branches: [main] + workflow_dispatch: + inputs: + mode: + description: > + Release mode: + - "version" — Creates a "Version Packages" PR that bumps versions and + updates CHANGELOG.md based on pending changesets. Run this first. + - "publish" — Publishes the package to npm with provenance. Only run + this AFTER the Version Packages PR has been merged to main. + NOTE: @openrouter/agent releases must be coordinated with @openrouter/sdk + because callModel changes are typically coupled with SDK type changes. + required: true + type: choice + options: + - version + - publish + default: version + dry-run: + description: > + Dry run: If enabled, simulates the release without making any changes. + Useful for verifying what would happen before committing to a release. + required: false + type: boolean + default: false permissions: contents: write # For creating releases and the Version Packages PR @@ -30,13 +52,23 @@ jobs: - run: pnpm run test - - name: Create Release PR or Publish - id: changesets + - name: Create Version Packages PR + if: inputs.mode == 'version' uses: changesets/action@v1 with: - publish: pnpm publish --no-git-checks --provenance --access public title: "chore: version packages" commit: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to npm + if: inputs.mode == 'publish' && !inputs.dry-run + run: pnpm publish --no-git-checks --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish to npm (dry run) + if: inputs.mode == 'publish' && inputs.dry-run + run: pnpm publish --no-git-checks --provenance --access public --dry-run + env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/src/lib/next-turn-params.test.ts b/src/lib/next-turn-params.test.ts new file mode 100644 index 0000000..04fd758 --- /dev/null +++ b/src/lib/next-turn-params.test.ts @@ -0,0 +1,107 @@ +import type * as models from '@openrouter/sdk/models'; + +import { describe, expect, it } from 'vitest'; +import { applyNextTurnParamsToRequest } from './next-turn-params.js'; + +/** + * Creates a minimal ResponsesRequest for testing applyNextTurnParamsToRequest. + */ +function createBaseRequest( + overrides?: Partial, +): models.ResponsesRequest { + return { + model: 'openai/gpt-4', + input: [{ role: 'user', content: 'hello' }], + ...overrides, + }; +} + +describe('applyNextTurnParamsToRequest', () => { + it('should pass through non-null values unchanged', () => { + const request = createBaseRequest(); + const result = applyNextTurnParamsToRequest(request, { + temperature: 0.5, + maxOutputTokens: 1000, + instructions: 'Be helpful', + }); + + expect(result.temperature).toBe(0.5); + expect(result.maxOutputTokens).toBe(1000); + expect(result.instructions).toBe('Be helpful'); + }); + + it('should convert null values to undefined', () => { + const request = createBaseRequest({ temperature: 0.7 }); + const result = applyNextTurnParamsToRequest(request, { + temperature: null, + maxOutputTokens: null, + instructions: null, + }); + + // null values become undefined, so the spread doesn't override + // with null — it overrides with undefined instead + expect(result.temperature).toBeUndefined(); + expect(result.maxOutputTokens).toBeUndefined(); + expect(result.instructions).toBeUndefined(); + }); + + it('should preserve original request fields not in computedParams', () => { + const request = createBaseRequest({ + model: 'anthropic/claude-3', + temperature: 0.9, + }); + const result = applyNextTurnParamsToRequest(request, { + maxOutputTokens: 500, + }); + + expect(result.model).toBe('anthropic/claude-3'); + expect(result.temperature).toBe(0.9); + expect(result.maxOutputTokens).toBe(500); + }); + + it('should handle empty computedParams without changing the request', () => { + const request = createBaseRequest({ temperature: 0.5 }); + const result = applyNextTurnParamsToRequest(request, {}); + + expect(result.temperature).toBe(0.5); + expect(result.model).toBe('openai/gpt-4'); + }); + + it('should handle mixed null and non-null values', () => { + const request = createBaseRequest({ + temperature: 0.7, + topP: 0.9, + }); + const result = applyNextTurnParamsToRequest(request, { + temperature: null, + topP: 0.5, + maxOutputTokens: null, + instructions: 'Updated instructions', + }); + + expect(result.temperature).toBeUndefined(); + expect(result.topP).toBe(0.5); + expect(result.maxOutputTokens).toBeUndefined(); + expect(result.instructions).toBe('Updated instructions'); + }); + + it('should preserve zero as a valid non-null value', () => { + const request = createBaseRequest(); + const result = applyNextTurnParamsToRequest(request, { + temperature: 0, + maxOutputTokens: 0, + }); + + expect(result.temperature).toBe(0); + expect(result.maxOutputTokens).toBe(0); + }); + + it('should preserve empty string as a valid non-null value', () => { + const request = createBaseRequest(); + const result = applyNextTurnParamsToRequest(request, { + instructions: '' as string | null, + }); + + expect(result.instructions).toBe(''); + }); +}); diff --git a/src/lib/next-turn-params.ts b/src/lib/next-turn-params.ts index 2a65088..ce0902f 100644 --- a/src/lib/next-turn-params.ts +++ b/src/lib/next-turn-params.ts @@ -170,8 +170,14 @@ export function applyNextTurnParamsToRequest( request: models.ResponsesRequest, computedParams: Partial, ): models.ResponsesRequest { + // Strip null values to undefined so they're compatible with ResponsesRequest + // fields that may be typed as `number | undefined` (without null) + const sanitized: Record = {}; + for (const [key, value] of Object.entries(computedParams)) { + sanitized[key] = value === null ? undefined : value; + } return { ...request, - ...computedParams, + ...sanitized, }; }