Compare commits

...

6 Commits

Author SHA1 Message Date
Chris Farhood 2d057f085d refactor: remove PAPERCLIP_DEV_API_KEY runtime hack throughout
Cancel poll now uses ctx.authToken exclusively. Remove forwarding of
PAPERCLIP_DEV_API_KEY into job pods and all associated tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:24:14 -04:00
Chris Farhood 570fdae9c4 fix(models): restore static model list matching opencode_local pattern
Adds back STATIC_MODELS with correct provider/model IDs and consistent
labels (matching listModels output format) so the UI is not blank before
listModels resolves. Restores server-adapter test with a contract check
that enforces provider/model label consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:18:46 -04:00
Chris Farhood 985d55e125 fix(cancel-poll): use ctx.authToken instead of process.env for cancel polling
The cancel poll was sending empty Authorization headers because
PAPERCLIP_API_KEY is not set on the Paperclip server pod. Use the
per-run authToken from ctx instead, which is the JWT issued by Paperclip
for this execution. PAPERCLIP_DEV_API_KEY still overrides for dev instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:11:47 -04:00
Chris Farhood 5e67a4dd3b docs: note tag-based publish workflow in CLAUDE.md 2026-04-26 21:59:17 -04:00
Chris Farhood 5f75c2b81b feat(models): port model discovery to match opencode_local adapter
Replace static hardcoded fallback list with the same robust approach used
by the opencode_local adapter: runChildProcess + ensurePathInEnv, HOME fix
via os.userInfo(), 60s TTL cache, returns [] on failure instead of a stale
list. Also updates CLAUDE.md and README.md with missing fields/features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:51:26 -04:00
Chris Farhood 7043e71ff6 fix(models): expose static models list so UI renders entries before listModels resolves
The UI was showing 0 models because the adapter only set listModels (async)
without a static models array. The static array is what the UI reads
synchronously to populate the selector — listModels is for refresh.

- Move the fallback list out as STATIC_MODELS (expanded to cover Opus/Sonnet/Haiku 4.x, GPT-4o/5, Gemini 2.5, Grok 4, DeepSeek)
- Set models: STATIC_MODELS on the adapter module
- Keep listK8sModels for runtime refresh from `opencode models` (with STATIC_MODELS fallback on error or empty stdout)
- Add server-adapter.test.ts asserting models is non-empty
- Add models.test.ts coverage for the empty-stdout fallback path

chore: bump version to 0.1.33

Fixes FAR-94.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 02:59:05 +00:00
12 changed files with 550 additions and 132 deletions
+22 -9
View File
@@ -13,6 +13,16 @@ npm run test:watch # Run vitest in watch mode
Run a single test file: `npx vitest run src/server/parse.test.ts`
## Publishing
Bump `version` in `package.json`, commit, push to `master`, then push a matching tag — the CI publish job only runs on `v*` tags:
```bash
git tag v0.1.x && git push origin v0.1.x
```
The workflow verifies the tag matches `package.json` version before publishing to npm.
## Architecture
This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Job pods. It exposes three entry points:
@@ -20,23 +30,24 @@ This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kuberne
- `.``src/index.ts` — main ServerAdapterModule factory
- `./server``src/server/index.ts` — server adapter internals
- `./ui-parser``src/ui-parser.ts` — real-time stdout line parser for the Paperclip UI
- `./cli``src/cli/index.ts` — CLI adapter module with console output formatter
### Execution Flow (`src/server/execute.ts`)
1. **Concurrency guard** — checks for existing running Jobs for the same agent (shared PVC/session enforcement)
2. **Self-pod introspection** (`getSelfPodInfo`) — queries own pod to inherit image, imagePullSecrets, DNS config, PVC mount, and all env vars from the Deployment
3. **Instructions + skill bundle resolution** — reads `instructionsFilePath` from config and desired skill markdown files from the PVC; content is prepended to the prompt at build time
4. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
- Init container (busybox) that writes the prompt to an emptyDir volume
- Main opencode container that pipes the prompt via stdin
4. **Agent DB PVC management** — if `agentDbMode: dedicated_pvc`, ensures a per-agent RWX PVC named `opencode-db-{agentId}` exists (creates it if missing), then mounts it at `/opencode-db` with `OPENCODE_DB=/opencode-db`; defaults to ephemeral emptyDir
5. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
- Prompt delivery: small prompts (< 256 KiB) via env var; large prompts via K8s Secret + busybox init container that copies to emptyDir, then piped via stdin
- Prompt assembled as: `[instructionsContent] + [skillsBundleContent] + bootstrapPrompt + wakePrompt + sessionHandoff + heartbeatPrompt`
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides; always sets `HOME=/paperclip` and `OPENCODE_DISABLE_PROJECT_CONFIG=true`
- Resource requests/limits, security contexts, tolerations, nodeSelector applied from config
5. **Job creation** — creates the Job in the target namespace
6. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
7. **Log streaming + completion wait** — streams pod logs to the Paperclip UI while waiting for Job completion (with configurable timeout)
8. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
9. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
6. **Job creation** — creates the Job in the target namespace
7. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
8. **Log streaming + completion wait** — streams pod logs with automatic reconnect on K8s API drops; `LogLineDedupFilter` (`log-dedup.ts`) deduplicates replayed lines on reconnect using structural keys (`type:sessionID:partId` for JSONL events, `raw:{content}` for plain lines)
9. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
10. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
### Skill Materialization (`src/server/skills.ts` + `src/server/execute.ts`)
@@ -84,5 +95,7 @@ src/
session.ts — sessionCodec (serialize/deserialize session params)
config-schema.ts — getConfigSchema() (adapter UI config fields)
test.ts — testEnvironment() (K8s environment health checks)
models.ts — static model list + dynamic fetch from `opencode models` CLI (with fallback)
log-dedup.ts — LogLineDedupFilter for reconnect replay deduplication
*.test.ts — vitest unit tests
```
+40 -13
View File
@@ -6,10 +6,12 @@ Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs i
- Spawns agent runs as K8s Jobs with full pod isolation
- Inherits container image, secrets, DNS, and PVC from the Paperclip Deployment automatically
- Real-time log streaming from Job pods back to the Paperclip UI
- Real-time log streaming from Job pods back to the Paperclip UI with automatic reconnect and replay deduplication
- Session resume via shared RWX PVC
- Per-agent concurrency guard
- Configurable resources, namespace, kubeconfig
- Skills bundle injection — skill markdown content prepended to each run prompt at execution time
- Optional per-agent database PVC (`agentDbMode: dedicated_pvc`) for persistent agent state across runs
- Configurable resources, namespace, kubeconfig, node selectors, and tolerations
- Runtime config injection for permission bypass
## Prerequisites
@@ -96,15 +98,15 @@ rules:
resources: ["namespaces"]
verbs: ["get"]
# Verify the RWX PVC exists and has the correct access mode
# Verify the RWX PVC; create/delete per-agent DB PVCs (agentDbMode: dedicated_pvc)
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get"]
verbs: ["get", "create", "delete"]
# Verify optional secrets exist (e.g. paperclip-secrets)
# Verify optional secrets; create/delete prompt-delivery Secrets for large prompts (> 256 KiB)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
verbs: ["create", "delete", "get"]
# RBAC self-test during adapter validation
- apiGroups: ["authorization.k8s.io"]
@@ -172,17 +174,40 @@ curl -X POST http://localhost:3100/api/adapters \
Agent-level configuration fields:
**Core**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `model` | **yes** | — | OpenCode model in `provider/model` format |
| `variant` | no | — | Reasoning profile variant |
| `instructionsFilePath` | no | — | Absolute path to a markdown file prepended to every run prompt (e.g. `/paperclip/.claude/projects/COMPANY/agents/AGENT/AGENTS.md`) |
| `dangerouslySkipPermissions` | no | `true` | Inject runtime config granting `permission.external_directory=allow` |
| `agentDbMode` | no | `ephemeral` | `ephemeral` (emptyDir, lost on exit) or `dedicated_pvc` (per-agent RWX PVC at `/opencode-db`) |
| `agentDbStorageClass` | no | Cluster default | StorageClass for dedicated agent DB PVC |
| `agentDbStorageCapacity` | no | `10Gi` | Storage size for dedicated agent DB PVC |
**Kubernetes**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `namespace` | no | Deployment namespace | K8s namespace for agent Jobs |
| `image` | no | Deployment image | Override container image for Jobs |
| `imagePullPolicy` | no | — | Image pull policy for Job pods |
| `kubeconfig` | no | In-cluster | Path to kubeconfig file |
| `serviceAccountName` | no | Default SA | Service account for Job pods |
| `resources` | no | See below | CPU/memory requests and limits |
| `nodeSelector` | no | — | Node selector key=value pairs (one per line) |
| `tolerations` | no | — | Pod tolerations in YAML format |
| `ttlSecondsAfterFinished` | no | `300` | Seconds before completed Jobs are auto-deleted |
| `retainJobs` | no | `false` | Keep completed Jobs for debugging (disables TTL) |
| `reattachOrphanedJobs` | no | `false` | Resume streaming if a matching Job is already running after adapter restart |
**Operational**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `timeoutSec` | no | `0` (none) | Run timeout in seconds |
| `retainJobs` | no | `false` | Keep completed Jobs for debugging |
| `graceSec` | no | `30` | Grace period after timeout before forceful termination |
| `env` | no | — | Additional environment variables for Jobs |
### Default Resource Requests and Limits
@@ -201,11 +226,13 @@ resources:
## How It Works
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables.
2. **Job creation** — each agent run creates a Kubernetes Job in the target namespace. The Job pod mounts the same RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the agent command.
3. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time.
4. **Concurrency guard** — only one Job per agent is allowed at a time (enforced via label selectors).
5. **Cleanup** — completed Jobs are automatically deleted after 300 seconds (`ttlSecondsAfterFinished`), or retained if `retainJobs` is enabled.
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables. This is cached for all subsequent runs in the same process.
2. **Concurrency guard** — only one Job per agent is allowed at a time, enforced via K8s label selectors before Job creation.
3. **Prompt assembly** — instructions file, skills markdown bundle, bootstrap prompt, session handoff, and heartbeat are concatenated in order. Prompts under 256 KiB are delivered via environment variable; larger prompts are written to a K8s Secret and copied into the pod by a busybox init container.
4. **Agent DB PVC** — if `agentDbMode: dedicated_pvc`, a per-agent RWX PVC named `opencode-db-{agentId}` is created if it does not exist, then mounted at `/opencode-db` with `OPENCODE_DB=/opencode-db`.
5. **Job creation** — a Kubernetes Job is created in the target namespace. The Job pod mounts the shared RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the OpenCode agent.
6. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time, with automatic reconnect on K8s API drops and replay deduplication to avoid duplicate output.
7. **Cleanup** — completed Jobs are automatically deleted after `ttlSecondsAfterFinished` seconds (default 300), or retained if `retainJobs` is enabled.
### Security Context
@@ -220,7 +247,7 @@ All Job pods run with a locked-down security context:
## Dependencies
- `@kubernetes/client-node` ^1.0.0
- `@paperclipai/adapter-utils` >=2026.411.0-canary.8 (peer dependency)
- `@paperclipai/adapter-utils` >=2026.415.0-canary.7 (peer dependency)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.32",
"version": "0.1.37",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+161 -12
View File
@@ -21,15 +21,39 @@ importers:
'@types/node':
specifier: ^24.6.0
version: 24.12.2
'@vitest/coverage-v8':
specifier: ^4.1.5
version: 4.1.5(vitest@4.1.5)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.1.4
version: 4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2))
version: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -39,9 +63,16 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsep-plugin/assignment@1.3.0':
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
engines: {node: '>= 10.16.0'}
@@ -104,42 +135,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -194,6 +219,15 @@ packages:
'@types/stream-buffers@3.0.8':
resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==}
'@vitest/coverage-v8@4.1.5':
resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
peerDependencies:
'@vitest/browser': 4.1.5
vitest: 4.1.5
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@@ -234,6 +268,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -390,6 +427,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -406,6 +447,9 @@ packages:
resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==}
engines: {node: '>=14'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@@ -415,9 +459,24 @@ packages:
peerDependencies:
ws: '*'
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -466,28 +525,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -508,6 +563,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -574,6 +636,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -606,6 +673,10 @@ packages:
streamx@2.25.0:
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tar-fs@3.1.2:
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
@@ -759,6 +830,21 @@ packages:
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -775,8 +861,15 @@ snapshots:
tslib: 2.8.1
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
@@ -905,6 +998,20 @@ snapshots:
dependencies:
'@types/node': 24.12.2
'@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.5
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -952,6 +1059,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
asynckit@0.4.0: {}
b4a@1.8.0: {}
@@ -1087,6 +1200,8 @@ snapshots:
gopd@1.2.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
@@ -1099,14 +1214,31 @@ snapshots:
hpagent@1.2.0: {}
html-escaper@2.0.2: {}
ip-address@10.1.0: {}
isomorphic-ws@5.0.0(ws@8.20.0):
dependencies:
ws: 8.20.0
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jose@6.2.2: {}
js-tokens@10.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -1172,6 +1304,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
@@ -1241,6 +1383,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
semver@7.7.4: {}
siginfo@2.0.0: {}
smart-buffer@4.2.0: {}
@@ -1275,6 +1419,10 @@ snapshots:
- bare-abort-controller
- react-native-b4a
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
tar-fs@3.1.2:
dependencies:
pump: 3.0.4
@@ -1342,7 +1490,7 @@ snapshots:
'@types/node': 24.12.2
fsevents: 2.3.3
vitest@4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)):
vitest@4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2))
@@ -1366,6 +1514,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
transitivePeerDependencies:
- msw
+4 -11
View File
@@ -56,12 +56,13 @@ const HAPPY_JSONL = [
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
].join("\n");
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}): AdapterExecutionContext {
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}, authToken = "test-auth-token"): AdapterExecutionContext {
return {
runId: "run-test-123",
agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null },
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
config: configOverrides,
authToken,
context: {
taskId: null,
issueId: null,
@@ -879,23 +880,16 @@ describe("execute — log dedup (waitForPod status dedup)", () => {
describe("execute — external cancel polling", () => {
const KEEPALIVE_MS = 15_000;
beforeEach(() => {
process.env.PAPERCLIP_DEV_API_KEY = "test-key";
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_DEV_API_KEY;
});
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
vi.useFakeTimers();
process.env.PAPERCLIP_API_URL = "http://test-api";
process.env.PAPERCLIP_API_KEY = "test-key";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -918,7 +912,7 @@ describe("execute — external cancel polling", () => {
});
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
const ctx = makeCtx({}, { issueId: "issue-test-456" });
const ctx = makeCtx({}, { issueId: "issue-test-456" }, "run-jwt-token");
const executePromise = execute(ctx);
// Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers
@@ -939,7 +933,7 @@ describe("execute — external cancel polling", () => {
);
expect(fetchMock).toHaveBeenCalledWith(
"http://test-api/api/issues/issue-test-456",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-key" }) }),
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer run-jwt-token" }) }),
);
});
@@ -958,7 +952,6 @@ describe("execute — external cancel polling", () => {
vi.useFakeTimers();
process.env.PAPERCLIP_API_URL = "http://test-api";
process.env.PAPERCLIP_API_KEY = "test-key";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
+1 -3
View File
@@ -569,9 +569,7 @@ async function streamAndAwaitJob(
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
if (logStopSignal.stopped || cancelSignal.cancelled) break;
try {
// Prefer PAPERCLIP_DEV_API_KEY if set (allows dev instance key to be
// distinct from the main-instance run JWT in PAPERCLIP_API_KEY).
const apiKey = process.env.PAPERCLIP_DEV_API_KEY ?? process.env.PAPERCLIP_API_KEY ?? "";
const apiKey = ctx.authToken ?? "";
const resp = await fetch(`${apiUrl}/api/issues/${issueId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
+2 -1
View File
@@ -1,7 +1,7 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import { type, agentConfigurationDoc } from "../index.js";
import { listK8sModels } from "./models.js";
import { listK8sModels, STATIC_MODELS } from "./models.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
@@ -14,6 +14,7 @@ export function createServerAdapter(): ServerAdapterModule {
execute,
testEnvironment,
sessionCodec,
models: STATIC_MODELS,
listModels: listK8sModels,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
+2 -3
View File
@@ -484,15 +484,14 @@ describe("buildJobManifest — env wiring branches", () => {
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
});
it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => {
it("inherits PAPERCLIP_API_URL from selfPod inheritedEnv", () => {
const selfPod = {
...mockSelfPod,
inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" },
inheritedEnv: { PAPERCLIP_API_URL: "http://api" },
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key");
});
});
-7
View File
@@ -173,13 +173,6 @@ function buildEnvVars(
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
}
// Inherit PAPERCLIP_DEV_API_KEY if set (dev-instance key, distinct from the
// main-instance run JWT in PAPERCLIP_API_KEY). Used by the external cancel
// polling in execute.ts to authenticate against the dev Paperclip instance.
if (selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY) {
paperclipEnv.PAPERCLIP_DEV_API_KEY = selfPod.inheritedEnv.PAPERCLIP_DEV_API_KEY;
}
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
const merged: Record<string, string> = {
...selfPod.inheritedEnv,
+122 -46
View File
@@ -1,78 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const execMock = vi.fn();
const runChildProcessMock = vi.fn();
vi.mock("child_process", () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, result: { stdout: string; stderr: string }) => void) => {
execMock(cmd, opts, cb);
},
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
return { ...actual, runChildProcess: runChildProcessMock };
});
const { listK8sModels } = await import("./models.js");
const { listK8sModels, discoverK8sModels, resetK8sModelsCacheForTests } = await import("./models.js");
function mockExecResult(stdout: string) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(null, { stdout, stderr: "" });
});
type MockResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
function mockSuccess(stdout: string): void {
runChildProcessMock.mockResolvedValue({ exitCode: 0, stdout, stderr: "", timedOut: false } satisfies MockResult);
}
function mockExecError(err: Error) {
execMock.mockImplementation((_cmd, _opts, cb) => {
cb(err, { stdout: "", stderr: "" });
});
function mockFailure(stderr = "ENOENT: opencode not found"): void {
runChildProcessMock.mockResolvedValue({ exitCode: 1, stdout: "", stderr, timedOut: false } satisfies MockResult);
}
function mockTimeout(): void {
runChildProcessMock.mockResolvedValue({ exitCode: null, stdout: "", stderr: "", timedOut: true } satisfies MockResult);
}
describe("listK8sModels", () => {
beforeEach(() => {
execMock.mockReset();
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("parses opencode models output into AdapterModel entries", async () => {
mockExecResult(
[
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
"google/gemini-2.5-pro",
].join("\n"),
);
it("parses provider/model lines into AdapterModel entries", async () => {
mockSuccess(["anthropic/claude-opus-4-7", "openai/gpt-4o", "google/gemini-2.5-pro"].join("\n"));
const models = await listK8sModels();
expect(models).toHaveLength(3);
expect(models[0]).toEqual({ id: "anthropic/claude-opus-4-7", label: "claude opus 4 7" });
expect(models[1]).toEqual({ id: "openai/gpt-4o", label: "gpt 4o" });
expect(models[2]).toEqual({ id: "google/gemini-2.5-pro", label: "gemini 2.5 pro" });
expect(models.find((m) => m.id === "anthropic/claude-opus-4-7")).toEqual({
id: "anthropic/claude-opus-4-7",
label: "anthropic/claude-opus-4-7",
});
});
it("ignores blank lines and trims whitespace", async () => {
mockExecResult("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
mockSuccess("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
const models = await listK8sModels();
expect(models.map((m) => m.id)).toEqual([
"anthropic/claude-opus-4-7",
"openai/gpt-4o",
]);
expect(models.map((m) => m.id)).toEqual(
["anthropic/claude-opus-4-7", "openai/gpt-4o"].sort(),
);
});
it("invokes opencode models with a timeout", async () => {
mockExecResult("anthropic/claude-opus-4-7");
it("skips lines without a provider/model slash", async () => {
mockSuccess("anthropic/claude-opus-4-7\nnot-a-model-line\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.map((m) => m.id)).not.toContain("not-a-model-line");
expect(models).toHaveLength(2);
});
it("deduplicates repeated model IDs", async () => {
mockSuccess("anthropic/claude-opus-4-7\nanthropic/claude-opus-4-7\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.filter((m) => m.id === "anthropic/claude-opus-4-7")).toHaveLength(1);
});
it("returns models sorted alphabetically by ID", async () => {
mockSuccess("openai/gpt-4o\nanthropic/claude-opus-4-7");
const models = await listK8sModels();
expect(models[0].id).toBe("anthropic/claude-opus-4-7");
expect(models[1].id).toBe("openai/gpt-4o");
});
it("returns empty array when the CLI fails", async () => {
mockFailure("ENOENT: opencode not found");
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI times out", async () => {
mockTimeout();
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI returns empty stdout", async () => {
mockSuccess("");
expect(await listK8sModels()).toEqual([]);
});
it("caches results and only calls the CLI once within the TTL", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await listK8sModels();
await listK8sModels();
expect(execMock).toHaveBeenCalledTimes(1);
const [cmd, opts] = execMock.mock.calls[0];
expect(cmd).toBe("opencode models");
expect(opts).toMatchObject({ timeout: 30_000 });
expect(runChildProcessMock).toHaveBeenCalledTimes(1);
});
it("falls back to the static list when the CLI fails", async () => {
mockExecError(new Error("ENOENT: opencode not found"));
it("re-fetches after the cache is reset", async () => {
mockSuccess("anthropic/claude-opus-4-7");
const models = await listK8sModels();
await listK8sModels();
resetK8sModelsCacheForTests();
await listK8sModels();
expect(models.length).toBeGreaterThan(0);
expect(models.map((m) => m.id)).toContain("anthropic/claude-opus-4-7");
expect(models.map((m) => m.id)).toContain("openai/gpt-4o");
expect(runChildProcessMock).toHaveBeenCalledTimes(2);
});
});
describe("discoverK8sModels", () => {
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("passes OPENCODE_DISABLE_PROJECT_CONFIG=true to the subprocess", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, , , opts] = runChildProcessMock.mock.calls[0] as [unknown, unknown, unknown, { env: Record<string, string> }];
expect(opts.env).toMatchObject({ OPENCODE_DISABLE_PROJECT_CONFIG: "true" });
});
it("invokes opencode with the models subcommand", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, command, args] = runChildProcessMock.mock.calls[0] as [unknown, string, string[]];
expect(command).toBe("opencode");
expect(args).toEqual(["models"]);
});
it("throws when the CLI exits non-zero", async () => {
mockFailure("provider not configured");
await expect(discoverK8sModels()).rejects.toThrow("opencode models` failed");
});
it("throws when the CLI times out", async () => {
mockTimeout();
await expect(discoverK8sModels()).rejects.toThrow("timed out");
});
});
+171 -26
View File
@@ -1,33 +1,178 @@
import { createHash } from "node:crypto";
import os from "node:os";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { exec } from "child_process";
import { promisify } from "util";
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
const execAsync = promisify(exec);
export const STATIC_MODELS: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "anthropic/claude-opus-4-7" },
{ id: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
{ id: "anthropic/claude-haiku-4-5", label: "anthropic/claude-haiku-4-5" },
{ id: "openai/gpt-4o", label: "openai/gpt-4o" },
{ id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" },
{ id: "google/gemini-2.5-flash", label: "google/gemini-2.5-flash" },
];
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
if (!firstToken.includes("/")) continue;
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
if (!provider || !model) continue;
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
}
return dedupeModels(parsed);
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput =
typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverK8sModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
let resolvedHome: string | undefined;
try {
resolvedHome = os.userInfo().homedir || undefined;
} catch {
// os.userInfo() throws when the UID has no /etc/passwd entry (e.g. distroless images)
}
const runtimeEnv = normalizeEnv(
ensurePathInEnv({
...process.env,
...env,
...(resolvedHome ? { HOME: resolvedHome } : {}),
OPENCODE_DISABLE_PROJECT_CONFIG: "true",
}),
);
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["models"],
{
cwd,
env: runtimeEnv,
timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`);
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
}
export async function discoverK8sModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverK8sModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function listK8sModels(): Promise<AdapterModel[]> {
try {
const result = await execAsync("opencode models", { timeout: 30_000 });
const output = result.stdout;
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
const models: AdapterModel[] = [];
for (const line of lines) {
if (!line) continue;
const parts = line.split("/");
const id = line;
const label = parts[parts.length - 1].replace(/-/g, " ").replace(/_/g, " ");
models.push({ id, label });
}
return models;
return await discoverK8sModelsCached();
} catch {
const fallback: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "openai/gpt-4o", label: "GPT-4o" },
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
];
return fallback;
return [];
}
}
}
export function resetK8sModelsCacheForTests() {
discoveryCache.clear();
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { createServerAdapter } from "./index.js";
describe("createServerAdapter", () => {
it("declares the opencode_k8s type", () => {
const adapter = createServerAdapter();
expect(adapter.type).toBe("opencode_k8s");
});
it("exposes a non-empty static models list so the UI renders before listModels resolves", () => {
const adapter = createServerAdapter();
expect(Array.isArray(adapter.models)).toBe(true);
expect(adapter.models!.length).toBeGreaterThan(0);
for (const m of adapter.models!) {
expect(m.id).toMatch(/^[^/]+\/.+/);
expect(m.label).toBe(m.id);
}
});
it("exposes listModels for dynamic model discovery", () => {
const adapter = createServerAdapter();
expect(typeof adapter.listModels).toBe("function");
});
});