diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da13ba8..161d141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,6 @@ jobs: - name: Test published package run: bun run test:package + + - name: Test isolated OpenCode v2 host + run: npm exec --yes --package=@opencode/cli@2.0.3 -- bun run test:host diff --git a/README.md b/README.md index 1a99a18..92d3b20 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ This plugin lets you write in your language while the model works in English — ## Install +Version 2 of this plugin uses OpenCode's **v2 public plugin API**. The verified release is OpenCode **2.0.3**. +For OpenCode v1, use `opencode-translate@1`. +Start a fresh v2 session when upgrading: v1 activation metadata and historical bilingual trailers are not migrated. + ```bash bun add -g opencode-translate ``` @@ -32,16 +36,23 @@ Add to `~/.config/opencode/opencode.jsonc`: ```jsonc { - "plugin": [ - ["opencode-translate", { - "model": "openai/gpt-5.4-mini", // model to use for translation - "variant": "minimal", // optional model variant / thinking effort - "lang": "Korean" // language you speak - }] + "$schema": "https://opencode.ai/config.json", + "plugins": [ + { + "package": "opencode-translate", + "options": { + "model": "openai/gpt-5.4-mini", // model to use for translation + "variant": "minimal", // optional model variant / thinking effort + "lang": "Korean" // language you speak + } + } ] } ``` +Quit and restart OpenCode after changing the plugin configuration. For a local build, run `bun install && bun run build` +and set `package` to the absolute **directory** `/path/to/opencode-translate/dist`. + ## Usage Prefix any message with `$en` to activate translation for that session. @@ -52,6 +63,66 @@ $en 프로젝트 루트의 package.json을 읽고 요약해줘 All subsequent messages in the same session are translated automatically — no need to repeat `$en`. +- Your original message and its English translation remain visible in the transcript. +- English assistant text streams normally; a translated Markdown section is appended when the text segment completes. +- Both the **terminal and web UI** display the same persisted bilingual assistant text. No UI-specific plugin is needed. +- Question forms are translated, with selected labels and custom answers converted back to English for the model. +- Translation activates only in root sessions. Its state survives plugin/server restarts. + +Before model requests, the plugin removes its recorded display translations from context. It does not remove arbitrary +Markdown based on its appearance. On inbound translation failure, the original prompt is sent unchanged; on outbound +failure, the English response is retained with a translation-unavailable notice. + +## Authentication + +Connect the translation model's provider in **OpenCode itself**. Translation uses the public `ctx.generate.text()` API, +so OpenCode owns provider selection, model variants, API keys, SQLite credentials, and OAuth refresh/persistence. +The plugin does not read `auth.json`/`auth-v2.json`, query the credential database, or maintain separate tokens. +Provider and OAuth support for the translation model is the support available in your OpenCode installation. + +## Inline reply support + +V2 has no `experimental.text.complete` hook or public display-only message append operation. Inline translations use +`session.hook("http.response", ...)` with adapters for these **main-chat response protocols**: + +| Protocol | Recognized endpoint | Support | +| --- | --- | --- | +| OpenAI Responses, including Codex | `…/responses` | SSE text deltas and final snapshots | +| OpenAI Chat Completions and compatible providers | `…/chat/completions` | SSE text choices | +| Anthropic Messages | `…/messages` | SSE text blocks | +| Gemini / Vertex Gemini | `…:streamGenerateContent` | SSE non-thinking text | + +Unknown endpoints, non-SSE responses, and binary protocols such as Bedrock Converse pass through unchanged with a server +log message. They still support inbound/question translation when the translation model is available through OpenCode. +Reasoning, tool calls, images, and other non-text outputs are not translated. + +**Transport:** OpenCode routes sessions through HTTP when HTTP hooks are registered. Loading this plugin therefore +disables the session WebSocket fast path in its location, including sessions that have not activated `$en`. +Translations add latency at text-completion boundaries and use additional model requests. Their usage is separate from +the primary model's reported token counts. + +## Development and verification + +```sh +bun install +bun run check:ci +bun run typecheck +bun run knip +bun test +bun run build +bun run test:package + +# Requires Node 24 and an OpenCode v2 binary; uses an isolated server and fake provider. +OPENCODE_BINARY=/path/to/opencode bun run test:host +``` + +Tests include OpenCode's actual native protocol parsers. The real-host smoke test loads the built plugin, creates and +rotates a test credential in an isolated SQLite database, checks bilingual persisted messages and English-only model +requests, and restarts the server to verify recovery. It does not use your live server, credentials, or paid models. + +The old `opencode2 v0.0.0-dev-18322` binary does not pass this migration's host smoke test. Use the verified 2.0.3 release +rather than assuming that any binary named `opencode2` exposes the current plugin API. + ## Options | Option | Type | Default | Description | diff --git a/bun.lock b/bun.lock index 93992d9..80fad4a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,51 +5,69 @@ "": { "name": "opencode-translate", "dependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.96", - "@ai-sdk/anthropic": "^3.0.71", - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/google-vertex": "^4.0.112", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/openai-compatible": "^2.0.41", - "ai": "^6.0.168", + "@opencode/plugin": "2.0.3", }, "devDependencies": { "@biomejs/biome": "^2.4.12", - "@opencode-ai/plugin": "^1.14.21", + "@opencode/ai": "2.0.3", "@types/node": "^25.6.0", "@typescript/native-preview": "beta", "bun-types": "^1.3.13", + "effect": "4.0.0-rc.112", "knip": "^6.14.1", }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.14.0", - }, }, }, "packages": { - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], - "@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="], + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], - "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="], + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1057.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@aws-sdk/core": ["@aws-sdk/core@3.978.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.70", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-KlU89w6Hmb4oZB5zFz/MNIhPOBQGVE7KrDr3BTPCwC4W+q566YH8tGNsAML781LKATtqmNCGFry8XvsJ2XPusg=="], - "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ=="], - "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.73", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-login": "^3.972.78", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.83", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.71", "@aws-sdk/credential-provider-http": "^3.972.73", "@aws-sdk/credential-provider-ini": "^3.973.16", "@aws-sdk/credential-provider-process": "^3.972.71", "@aws-sdk/credential-provider-sso": "^3.973.15", "@aws-sdk/credential-provider-web-identity": "^3.972.77", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.71", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/token-providers": "3.1129.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1057.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1057.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.45", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1129.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.0", "@aws-sdk/nested-clients": "^3.997.45", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], "@biomejs/biome": ["@biomejs/biome@2.4.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.12", "@biomejs/cli-darwin-x64": "2.4.12", "@biomejs/cli-linux-arm64": "2.4.12", "@biomejs/cli-linux-arm64-musl": "2.4.12", "@biomejs/cli-linux-x64": "2.4.12", "@biomejs/cli-linux-x64-musl": "2.4.12", "@biomejs/cli-win32-arm64": "2.4.12", "@biomejs/cli-win32-x64": "2.4.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Rro7adQl3NLq/zJCIL98eElXKI8eEiBtoeu5TbXF/U3qbjuSc7Jb5rjUbeHHcquDWeSf3HnGP7XI5qGrlRk/pA=="], @@ -69,32 +87,106 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.12", "", { "os": "win32", "cpu": "x64" }, "sha512-yMckRzTyZ83hkk8iDFWswqSdU8tvZxspJKnYNh7JZr/zhZNOlzH13k4ecboU6MurKExCe2HUkH75pGI/O2JwGA=="], + "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="], + + "@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.21", "", { "dependencies": { "@opencode-ai/sdk": "1.14.21", "effect": "4.0.0-beta.48", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.99", "@opentui/solid": ">=0.1.99" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-EZ0DlBQPL8udvBdZ+vZ7pnu7GBMqyCn0wQwUCjxb/OkbOqCQ7gkaedHgil5GzP8sKrgNHN/T2/jNQ/LGIeDdwA=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], + + "@npmcli/installed-package-contents": ["@npmcli/installed-package-contents@4.0.0", "", { "dependencies": { "npm-bundled": "^5.0.0", "npm-normalize-package-bin": "^5.0.0" }, "bin": { "installed-package-contents": "bin/index.js" } }, "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA=="], + + "@npmcli/map-workspaces": ["@npmcli/map-workspaces@5.0.3", "", { "dependencies": { "@npmcli/name-from-folder": "^4.0.0", "@npmcli/package-json": "^7.0.0", "glob": "^13.0.0", "minimatch": "^10.0.3" } }, "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw=="], + + "@npmcli/metavuln-calculator": ["@npmcli/metavuln-calculator@9.0.3", "", { "dependencies": { "cacache": "^20.0.0", "json-parse-even-better-errors": "^5.0.0", "pacote": "^21.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5" } }, "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg=="], + + "@npmcli/name-from-folder": ["@npmcli/name-from-folder@4.0.0", "", {}, "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg=="], + + "@npmcli/node-gyp": ["@npmcli/node-gyp@5.0.0", "", {}, "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ=="], + + "@npmcli/package-json": ["@npmcli/package-json@7.0.5", "", { "dependencies": { "@npmcli/git": "^7.0.0", "glob": "^13.0.0", "hosted-git-info": "^9.0.0", "json-parse-even-better-errors": "^5.0.0", "proc-log": "^6.0.0", "semver": "^7.5.3", "spdx-expression-parse": "^4.0.0" } }, "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-WA9a1hTn2Nzjs6rI2fiMlPrBghmsFi771ABRxKlGZkTfT0PpKemJr6LP4vu+PilINFseLze2ZKmylRr84xFHuA=="], + "@npmcli/promise-spawn": ["@npmcli/promise-spawn@9.0.1", "", { "dependencies": { "which": "^6.0.0" } }, "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q=="], + + "@npmcli/query": ["@npmcli/query@5.0.0", "", { "dependencies": { "postcss-selector-parser": "^7.0.0" } }, "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + + "@npmcli/run-script": ["@npmcli/run-script@10.0.4", "", { "dependencies": { "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", "proc-log": "^6.0.0" } }, "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg=="], + + "@opencode/ai": ["@opencode/ai@2.0.3", "", { "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "2.0.3", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-rc.112", "google-auth-library": "10.5.0" } }, "sha512-SqrFdmgwCDWug95v7vpkT8asd64i/iimK67BVzvuCbtLfzWdUljzp3No4ytHXyVqLt/XkdyOuJA/NoGrDM8muw=="], + + "@opencode/client": ["@opencode/client@2.0.3", "", { "dependencies": { "@opencode/protocol": "2.0.3", "@opencode/schema": "2.0.3" }, "peerDependencies": { "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-b+LcpMI131fXnGqf+O+9Y33H4XmwAkQibfYvtpEOROftkTpkcMj3R7WW/wGl2VTBNTZECAk2Yd+TACTzzAwk0A=="], + + "@opencode/plugin": ["@opencode/plugin@2.0.3", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "2.0.3", "@opencode/client": "2.0.3", "@opencode/protocol": "2.0.3", "@opencode/schema": "2.0.3", "@opencode/util": "2.0.3", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { "@opencode/theme": "2.0.3", "@opentui/core": ">=0.5.10", "@opentui/solid": ">=0.5.10", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode/theme", "@opentui/core", "@opentui/solid", "solid-js"] }, "sha512-4xLLwi9PnUXHtfvsxVe7oVhRl/q8VRIFQS4Lfh3yjClfymIfyfX5/jCSwSbgTf5NgdrNGr20OPcjONZvCTPznA=="], + + "@opencode/protocol": ["@opencode/protocol@2.0.3", "", { "dependencies": { "@opencode/schema": "2.0.3", "effect": "4.0.0-rc.112" } }, "sha512-DNHbkLDTuAyMsmVdnjuKulcfoDbllW2dxt4W19r2aJEYvswJpEO9168CSK6Sjbx1FOh/B9yL7SQgoDRMd4K5Vw=="], + + "@opencode/schema": ["@opencode/schema@2.0.3", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.112" } }, "sha512-thPeBbqw4+SkZ/0LOuqfahSRGuWogRa/wwfIVyiQYsoE0vzLx8XUpHQ+FSUWkdz6llDtP0+ZsyBqXUMpXmPgbg=="], + + "@opencode/util": ["@opencode/util@2.0.3", "", { "dependencies": { "@effect/opentelemetry": "4.0.0-rc.112", "@effect/platform-node": "4.0.0-rc.112", "@effect/platform-node-shared": "4.0.0-rc.112", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "cross-spawn": "7.0.6", "effect": "4.0.0-rc.112", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", "npm-package-arg": "13.0.2", "pacote": "21.5.1" } }, "sha512-6DQyOUtJW0kU6pLBEpncIyUH1nmXa8Z2jcsBEYummKmHCwZaiIkBowjWMGWKsV4G42IPMP1jYUMfD9hTRmX+RA=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + + "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw=="], + + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + + "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.130.0", "", { "os": "android", "cpu": "arm" }, "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.130.0", "", { "os": "android", "cpu": "arm64" }, "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg=="], @@ -177,11 +269,61 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@redis/bloom": ["@redis/bloom@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag=="], + + "@redis/client": ["@redis/client@6.2.1", "", { "dependencies": { "cluster-key-slot": "1.1.2" }, "peerDependencies": { "@node-rs/xxhash": "^1.1.0", "@opentelemetry/api": ">=1 <2" }, "optionalPeers": ["@node-rs/xxhash", "@opentelemetry/api"] }, "sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q=="], + + "@redis/json": ["@redis/json@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ=="], + + "@redis/search": ["@redis/search@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ=="], + + "@redis/time-series": ["@redis/time-series@6.2.1", "", { "peerDependencies": { "@redis/client": "^6.2.1" } }, "sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw=="], + + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + + "@smithy/core": ["@smithy/core@3.34.1", "", { "dependencies": { "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], - "@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], @@ -191,10 +333,16 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260421.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260421.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260421.2" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw=="], @@ -211,24 +359,46 @@ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg=="], - "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], + "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], - "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "bin-links": ["bin-links@6.0.2", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + + "cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -237,13 +407,15 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "fast-check": ["fast-check@4.10.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -251,91 +423,217 @@ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="], + + "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], + + "just-diff": ["just-diff@6.0.2", "", {}, "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA=="], + + "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], "knip": ["knip@6.14.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "minimist": "^1.2.8", "oxc-parser": "^0.130.0", "oxc-resolver": "^11.19.1", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g=="], - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.10", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA=="], + "msgpackr": ["msgpackr@2.1.0", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ=="], - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], + + "npm-bundled": ["npm-bundled@5.0.0", "", { "dependencies": { "npm-normalize-package-bin": "^5.0.0" } }, "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw=="], + + "npm-install-checks": ["npm-install-checks@8.0.0", "", { "dependencies": { "semver": "^7.1.1" } }, "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA=="], + + "npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "npm-packlist": ["npm-packlist@10.0.4", "", { "dependencies": { "ignore-walk": "^8.0.0", "proc-log": "^6.0.0" } }, "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng=="], + + "npm-pick-manifest": ["npm-pick-manifest@11.0.3", "", { "dependencies": { "npm-install-checks": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "npm-package-arg": "^13.0.0", "semver": "^7.3.5" } }, "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ=="], + + "npm-registry-fetch": ["npm-registry-fetch@19.1.1", "", { "dependencies": { "@npmcli/redact": "^4.0.0", "jsonparse": "^1.3.1", "make-fetch-happen": "^15.0.0", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minizlib": "^3.0.1", "npm-package-arg": "^13.0.0", "proc-log": "^6.0.0" } }, "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw=="], + "oxc-parser": ["oxc-parser@0.130.0", "", { "dependencies": { "@oxc-project/types": "^0.130.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.130.0", "@oxc-parser/binding-android-arm64": "0.130.0", "@oxc-parser/binding-darwin-arm64": "0.130.0", "@oxc-parser/binding-darwin-x64": "0.130.0", "@oxc-parser/binding-freebsd-x64": "0.130.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", "@oxc-parser/binding-linux-arm64-musl": "0.130.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-gnu": "0.130.0", "@oxc-parser/binding-linux-x64-musl": "0.130.0", "@oxc-parser/binding-openharmony-arm64": "0.130.0", "@oxc-parser/binding-wasm32-wasi": "0.130.0", "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", "@oxc-parser/binding-win32-x64-msvc": "0.130.0" } }, "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw=="], "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], + + "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], + + "parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "proggy": ["proggy@4.0.0", "", {}, "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ=="], + + "promise-all-reject-late": ["promise-all-reject-late@1.0.1", "", {}, "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw=="], + + "promise-call-limit": ["promise-call-limit@3.0.2", "", {}, "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + "read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="], + + "redis": ["redis@6.2.1", "", { "dependencies": { "@redis/bloom": "6.2.1", "@redis/client": "6.2.1", "@redis/json": "6.2.1", "@redis/search": "6.2.1", "@redis/time-series": "6.2.1" } }, "sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="], + + "spdx-expression-parse": ["spdx-expression-parse@4.0.0", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ=="], + + "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + "unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="], + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], @@ -343,18 +641,60 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - "effect/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@npmcli/promise-spawn/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@opencode/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/undici": ["undici@6.28.1", "", {}, "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA=="], + + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], + + "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@aws-sdk/types/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], } } diff --git a/index.d.ts b/index.d.ts index 3dc3312..b743482 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,5 @@ -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin } from "@opencode/plugin" -export declare const OpencodeTranslate: Plugin +export declare const OpencodeTranslate: Plugin.Plugin export default OpencodeTranslate diff --git a/knip.json b/knip.json index 14dfc5c..d4deae8 100644 --- a/knip.json +++ b/knip.json @@ -1,14 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "project": ["src/**/*.ts", "test/**/*.ts"], - "ignoreDependencies": [ - "@ai-sdk/amazon-bedrock", - "@ai-sdk/anthropic", - "@ai-sdk/google", - "@ai-sdk/google-vertex", - "@ai-sdk/openai", - "@ai-sdk/openai-compatible" - ], "ignoreIssues": { "index.d.ts": ["duplicates"], "src/index.ts": ["duplicates"] diff --git a/package.json b/package.json index 1d70475..1c02534 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-translate", - "version": "1.0.7", + "version": "2.0.0", "description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.", "type": "module", "main": "dist/index.js", @@ -43,27 +43,20 @@ "test": "bun test", "test:coverage": "bun test --coverage", "test:package": "node scripts/package-smoke.mjs", + "test:host": "bun run build && node scripts/host-smoke.mjs", "prepare": "bun run build", "prepack": "bun run build" }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.14.0" - }, "dependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.96", - "@ai-sdk/anthropic": "^3.0.71", - "@ai-sdk/google": "^3.0.64", - "@ai-sdk/google-vertex": "^4.0.112", - "@ai-sdk/openai": "^3.0.53", - "@ai-sdk/openai-compatible": "^2.0.41", - "ai": "^6.0.168" + "@opencode/plugin": "2.0.3" }, "devDependencies": { "@biomejs/biome": "^2.4.12", - "@opencode-ai/plugin": "^1.14.21", + "@opencode/ai": "2.0.3", "@types/node": "^25.6.0", "@typescript/native-preview": "beta", "bun-types": "^1.3.13", - "knip": "^6.14.1" + "knip": "^6.14.1", + "effect": "4.0.0-rc.112" } } diff --git a/scripts/host-smoke.mjs b/scripts/host-smoke.mjs new file mode 100644 index 0000000..db7b0de --- /dev/null +++ b/scripts/host-smoke.mjs @@ -0,0 +1,230 @@ +import assert from "node:assert/strict" +import { spawn } from "node:child_process" +import { once } from "node:events" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import path from "node:path" +import { DatabaseSync } from "node:sqlite" +import { setTimeout as sleep } from "node:timers/promises" +import { fileURLToPath } from "node:url" + +// An isolated real OpenCode server, a local fake provider, and real SQLite. No +// user configuration, credentials, sessions, or external model requests are used. +const root = await mkdtemp(path.join(process.env.OPENCODE_TRANSLATE_TEST_TMP ?? tmpdir(), "translate-host-")) +const entrypoint = fileURLToPath(new URL("../dist/index.js", import.meta.url)) +const requests = [] +let key = "test-sqlite-key" +let child +let logs = "" +const provider = createServer(async (req, res) => { + let text = "" + for await (const chunk of req) text += chunk + const input = JSON.parse(text) + requests.push({ input, authorization: req.headers.authorization }) + if (req.headers.authorization !== `Bearer ${key}`) { + res.writeHead(401).end("Unexpected credential") + return + } + const serialized = JSON.stringify(input.messages) + const content = + input.model === "translator" + ? serialized.includes("from Korean to English") + ? "Hello" + : "안녕하세요" + : "Hello from the assistant" + res.writeHead(200, { "content-type": "text/event-stream" }) + res.end( + `${[ + { id: "chat_test", choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }] }, + { + id: "chat_test", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join("")}data: [DONE]\n\n`, + ) +}) + +async function stop() { + if (!child || child.exitCode !== null) return + const exited = once(child, "exit") + child.kill("SIGTERM") + const force = setTimeout(() => child.kill("SIGKILL"), 5000) + await exited + clearTimeout(force) +} + +try { + await Promise.all(["config", "project", "cache", "data", "state"].map((name) => mkdir(path.join(root, name)))) + provider.listen(0, "127.0.0.1") + await once(provider, "listening") + const providerURL = `http://127.0.0.1:${provider.address().port}/v1` + const config = { + $schema: "https://opencode.ai/config.json", + plugins: [{ package: path.dirname(entrypoint), options: { model: "translate-test/translator", lang: "Korean" } }], + model: "translate-test/main", + snapshots: false, + providers: { + "translate-test": { + name: "Isolated test provider", + package: "@opencode/ai/providers/openai/chat", + settings: { baseURL: providerURL }, + models: { main: {}, translator: {} }, + }, + }, + } + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("OPENCODE_"))) + await writeFile(path.join(root, "config", "opencode.json"), JSON.stringify(config)) + Object.assign(env, { + HOME: root, + OPENCODE_TEST_HOME: root, + OPENCODE_CONFIG_DIR: path.join(root, "config"), + OPENCODE_CONFIG: path.join(root, "config", "opencode.json"), + OPENCODE_CONFIG_PROJECT_DISABLE: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", + OPENCODE_DISABLE_FILEWATCHER: "true", + OPENCODE_DISABLE_FFF: "true", + OPENCODE_DB: path.join(root, "opencode.db"), + OPENCODE_PASSWORD: "isolated-test", + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_STATE_HOME: path.join(root, "state"), + }) + let address + async function api(route, body) { + const url = new URL(route, address) + url.searchParams.set("location[directory]", path.join(root, "project")) + const response = await fetch(url, { + method: body === undefined ? "GET" : "POST", + headers: { + authorization: `Basic ${Buffer.from("opencode:isolated-test").toString("base64")}`, + "content-type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }) + const text = await response.text() + if (!response.ok) throw new Error(`${route}: ${response.status} ${text}`) + return text ? JSON.parse(text) : undefined + } + async function start() { + const reservation = createServer() + reservation.listen(0, "127.0.0.1") + await once(reservation, "listening") + const port = reservation.address().port + await new Promise((resolve) => reservation.close(resolve)) + address = `http://127.0.0.1:${port}` + child = spawn( + process.env.OPENCODE_BINARY ?? "opencode2", + ["serve", "--hostname", "127.0.0.1", "--port", String(port)], + { cwd: path.join(root, "project"), env, stdio: ["ignore", "pipe", "pipe"] }, + ) + child.stdout.on("data", (data) => { + logs += data + }) + child.stderr.on("data", (data) => { + logs += data + }) + let failure + for (let attempt = 0; attempt < 100; attempt++) { + if (child.exitCode !== null) throw new Error(`OpenCode exited: ${logs}`) + try { + await api("/api/plugin/await-activation", {}).catch((error) => { + if (!String(error).includes(": 404")) throw error + }) + const plugins = await api("/api/plugin") + if ( + plugins.data?.some( + (plugin) => plugin.id === "opencode-translate" && (!plugin.state || plugin.state.status === "active"), + ) + ) + return + failure = new Error( + `Plugin not active: ${JSON.stringify(plugins.data?.filter((plugin) => plugin.source.type !== "builtin"))}`, + ) + } catch (error) { + failure = error + } + await sleep(200) + } + console.error("Config diagnostics:", await api("/api/config").catch(String)) + throw failure + } + await start() + const plugins = await api("/api/plugin") + assert( + plugins.data.some( + (plugin) => plugin.id === "opencode-translate" && (!plugin.state || plugin.state.status === "active"), + ), + JSON.stringify(plugins), + ) + await api("/api/integration/translate-test/connect/key", { key }) + const db = new DatabaseSync(path.join(root, "opencode.db"), { readOnly: true }) + const credential = db.prepare("SELECT value FROM credential WHERE integration_id = ?").get("translate-test") + assert.equal(JSON.parse(credential.value).key, key) + db.close() + const created = await api("/api/session", { + title: "Translation smoke test", + location: { directory: path.join(root, "project") }, + model: { providerID: "translate-test", id: "main" }, + }) + const sessionID = created.data.id + await api(`/api/session/${sessionID}/prompt`, { text: "$en 안녕하세요" }) + await api(`/api/session/${sessionID}/wait`, {}) + const first = await api(`/api/session/${sessionID}/context`) + assert( + first.data.some((message) => message.type === "user" && message.text.includes("→ EN: Hello")), + JSON.stringify(first), + ) + assert( + first.data.some( + (message) => + message.type === "assistant" && + message.content.some((part) => part.type === "text" && part.text.includes("안녕하세요")), + ), + JSON.stringify(first), + ) + assert(requests.some(({ input }) => input.model === "translator")) + assert( + requests + .filter(({ input }) => input.model === "main") + .every(({ input }) => !JSON.stringify(input.messages).includes("안녕하세요")), + ) + await stop() + await start() + key = "rotated-sqlite-key" + await api("/api/integration/translate-test/connect/key", { key }) + const before = requests.length + await api(`/api/session/${sessionID}/prompt`, { text: "다음 질문" }) + await api(`/api/session/${sessionID}/wait`, {}) + const next = requests.slice(before) + assert( + next.some(({ input }) => input.model === "translator"), + "activation must survive restart", + ) + assert( + next.every((request) => request.authorization === `Bearer ${key}`), + "must use the rotated SQLite credential", + ) + assert( + next + .filter(({ input }) => input.model === "main") + .every(({ input }) => !/[가-힣]/.test(JSON.stringify(input.messages))), + "history sent to main model must be English", + ) + console.log( + "Real OpenCode smoke passed: plugin activation, SQLite credentials/rotation, bilingual persisted transcript, English model context, restart recovery.", + ) +} catch (error) { + console.error(logs) + throw error +} finally { + await stop() + provider.closeAllConnections() + await new Promise((resolve) => provider.close(resolve)) + await rm(root, { recursive: true, force: true }) +} diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index dd98e8e..8d1bb6e 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -41,9 +41,30 @@ try { import assert from "node:assert/strict" process.env.OPENCODE_TRANSLATE_DISABLE = "1" const plugin = await import("opencode-translate") - assert.equal(typeof plugin.default, "function") + assert.equal(plugin.default.id, "opencode-translate") + assert.equal(typeof plugin.default.setup, "function") assert.equal(plugin.default, plugin.OpencodeTranslate) - assert.deepEqual(await plugin.default({ client: {}, directory: process.cwd() }, {}), {}) + assert.equal(await plugin.default.setup({}), undefined) + delete process.env.OPENCODE_TRANSLATE_DISABLE + const hooks = [] + const hook = (domain) => async (name) => { + hooks.push(domain + "." + name) + return { dispose: async () => {} } + } + const cleanup = await plugin.default.setup({ + app: { version: "2.0.3" }, + options: { model: "openai/gpt-5.4-mini", lang: "Korean" }, + session: { hook: hook("session") }, + tool: { hook: hook("tool") }, + storage: {}, + generate: {}, + }) + assert(hooks.includes("session.prompt")) + assert(hooks.includes("session.http.response")) + assert(hooks.includes("session.compaction")) + assert(hooks.includes("tool.execute.after")) + assert.equal(typeof cleanup, "function") + await cleanup() `, ], consumer, diff --git a/src/activation.ts b/src/activation.ts index 3294191..8583770 100644 --- a/src/activation.ts +++ b/src/activation.ts @@ -1 +1,112 @@ -export * from "./activation/index" +import type { Plugin } from "@opencode/plugin" +import type { SessionRequest } from "@opencode/plugin/promise/session" +import { LLM_LANGUAGE, PLUGIN_NAME, resolveOptions } from "./constants" +import { isQuestionArgs } from "./question-tool" +import { registerQuestionHooks } from "./questions" +import { translateResponse } from "./response" +import { createState, METADATA_KEY, readMetadata } from "./state" +import { createTranslator } from "./translator" + +export async function setup(ctx: Plugin.Context) { + if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return + const options = resolveOptions(ctx.options) + const controller = new AbortController() + const translator = createTranslator(ctx, options, controller.signal) + const state = createState(ctx) + + await ctx.session.hook("prompt", async (event) => { + const session = await ctx.session.get({ sessionID: event.sessionID }) + if (session.parentID) return + // Hooks can be invoked more than once during concurrent admission retries. + const existing = readMetadata(event.metadata?.[METADATA_KEY]) + if (existing?.display === event.prompt.text) return + const lang = await state.language(event.sessionID) + const source = lang ? event.prompt.text : stripTrigger(event.prompt.text, options.trigger) + if (source === undefined) return + const userLanguage = lang ?? options.lang + try { + const english = await translator.text(source, userLanguage, LLM_LANGUAGE) + const display = source === english ? source : `${source}\n\n→ EN: ${english}` + await state.remember(event.sessionID, display, english) + await ctx.storage.set(`sessions/${event.sessionID}`, userLanguage) + event.prompt.text = display + // Mentions refer to offsets in the submitted text, which rewriting invalidates. + for (const attachment of [ + ...(event.prompt.files ?? []), + ...(event.prompt.agents ?? []), + ...(event.prompt.skills ?? []), + ]) { + delete attachment.mention + } + event.metadata = { ...event.metadata, [METADATA_KEY]: { lang: userLanguage, english, display } } + } catch (error) { + console.error(`[${PLUGIN_NAME}] inbound translation failed; sending original text`, error) + } + }) + + async function context(event: SessionRequest) { + // Metadata identifies inbound text; exact stored records identify outbound trailers. + // Never strip arbitrary Markdown that merely resembles a translation. + event.messages = await Promise.all( + event.messages.map(async (message) => { + const inbound = readMetadata(message.metadata?.[METADATA_KEY]) + const content = await Promise.all( + message.content.map(async (part) => { + if (part.type === "tool-call" && part.name === "question") { + const saved = await state.question(event.sessionID, part.id) + if (typeof saved === "string") { + const input: unknown = JSON.parse(saved) + if (isQuestionArgs(input)) return { ...part, input } + } + } + if (part.type !== "text") return part + const text = + inbound?.display === part.text ? inbound.english : await state.english(event.sessionID, part.text) + return { ...part, text } + }), + ) + const metadata = { ...message.metadata } + delete metadata[METADATA_KEY] + return { ...message, content, metadata } + }), + ) + } + await ctx.session.hook("context", context) + await ctx.session.hook("title", context) + // The pre-release v2 checkout (1.x app version) routes these through context. + // Released v2 hosts dispatch separate hooks, as specified in the migration guide. + if (Number.parseInt(ctx.app.version, 10) >= 2) { + await ctx.session.hook("compaction", context) + await ctx.session.hook("generate", context) + } + const clearQuestions = await registerQuestionHooks(ctx, state, translator) + await ctx.session.hook("http.response", async (event) => { + if (event.kind !== "primary" || !event.response.ok) return + const lang = await state.language(event.sessionID) + if (!lang || lang === LLM_LANGUAGE) return + event.response = translateResponse(event.request, event.response, { + lang, + signal: controller.signal, + translate: (text, signal) => translator.text(text, LLM_LANGUAGE, lang, signal), + remember: (display, english) => state.remember(event.sessionID, display, english), + warn: (message) => console.error(`[${PLUGIN_NAME}] ${message}`), + }) + }) + return () => { + controller.abort() + clearQuestions() + } +} + +export function stripTrigger(text: string, keywords: string[]): string | undefined { + const matches = keywords + .flatMap((keyword) => { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const match = new RegExp(`(^|\\s)${escaped}(?=$|\\s)`).exec(text) + return match ? [{ keyword, offset: match.index + match[1].length }] : [] + }) + .sort((a, b) => a.offset - b.offset) + const match = matches[0] + if (!match) return + return `${text.slice(0, match.offset)}${text.slice(match.offset + match.keyword.length).replace(/^ /, "")}` +} diff --git a/src/activation/chat-message.ts b/src/activation/chat-message.ts deleted file mode 100644 index 1935e33..0000000 --- a/src/activation/chat-message.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { Hooks } from "@opencode-ai/plugin" -import { - buildInboundTranslationError, - isUserAuthoredTextPart, - LLM_LANGUAGE, - NONCE_PATTERN, - normalizeReason, - type TextPartLike, - type TranslateState, -} from "../constants" -import { hashText } from "../translator" -import { logError } from "./logging" -import { mergeTranslatedMetadata } from "./metadata" -import { createActivationBannerPart, createActivationBannerText, createLlmOnlyTextPart } from "./parts" -import { cacheSessionState, createState, resolveSessionState } from "./state" -import { findTriggerMatch, stripTriggerKeyword } from "./trigger" -import { type HookContext, INACTIVE_ROOT_SESSION, type ResolvedSessionState } from "./types" - -type ChatMessageHook = NonNullable -type ChatMessageInput = Parameters[0] -type ChatMessageOutput = Parameters[1] - -interface ActivationResult { - state?: TranslateState - activatedThisTurn: boolean - aborted: boolean -} - -interface PartProcessingResult { - nextParts: TextPartLike[] - eligibleIndex: number - errors: { part: TextPartLike; error: unknown }[] - firstUserTextPart?: TextPartLike & { text: string } -} - -interface ExistingTranslation { - source: string - english: string -} - -const INLINE_ENGLISH_MARKER = "\n\n→ EN: " - -function extractExistingTranslation(ctx: HookContext, text: string): ExistingTranslation | undefined { - const bannerSuffix = `\n\n${createActivationBannerText(ctx.options)}` - const content = text.endsWith(bannerSuffix) ? text.slice(0, -bannerSuffix.length) : text - const markerIndex = content.lastIndexOf(INLINE_ENGLISH_MARKER) - if (markerIndex < 0) return - - const english = content.slice(markerIndex + INLINE_ENGLISH_MARKER.length) - if (english.trim().length === 0) return - return { source: content.slice(0, markerIndex), english } -} - -async function activateFromTrigger( - ctx: HookContext, - input: ChatMessageInput, - output: ChatMessageOutput, - resolved: ResolvedSessionState, -): Promise { - if (resolved.state || !resolved.canActivate) - return { state: resolved.state, activatedThisTurn: false, aborted: false } - - const match = findTriggerMatch(output.parts as TextPartLike[], ctx.options.trigger) - if (!match) return { activatedThisTurn: false, aborted: false } - - const part = output.parts[match.partArrayIndex] as TextPartLike & { text: string } - const originalText = part.text - part.text = stripTriggerKeyword(part.text, match.keyword, match.offset) - const state = createState(ctx.options) - if (!NONCE_PATTERN.test(state.translate_nonce)) { - part.text = originalText - await logError(ctx.client, new Error("Generated invalid translation nonce")) - return { activatedThisTurn: false, aborted: true } - } - - cacheSessionState(input.sessionID, state) - return { state, activatedThisTurn: true, aborted: false } -} - -async function translateUserPart( - ctx: HookContext, - state: TranslateState, - part: TextPartLike & { text: string }, - eligibleIndex: number, - nextParts: TextPartLike[], - errors: { part: TextPartLike; error: unknown }[], -) { - try { - const existing = extractExistingTranslation(ctx, part.text) - const source = existing?.source ?? part.text - const english = - existing?.english ?? - (await ctx.translator.translateText({ - text: source, - sourceLanguage: state.translate_user_lang, - targetLanguage: LLM_LANGUAGE, - direction: "inbound", - })) - const sourceHash = hashText(source) - part.metadata = { - ...(part.metadata ?? {}), - ...mergeTranslatedMetadata(state, { ...part, text: source }, english), - } - if (!existing) part.text = `${source}${INLINE_ENGLISH_MARKER}${english}` - nextParts.push( - createLlmOnlyTextPart(part.sessionID, part.messageID, english, { - translate_role: "llm_only_translation", - translate_nonce: state.translate_nonce, - translate_source_hash: sourceHash, - translate_part_index: eligibleIndex, - }), - ) - } catch (error) { - errors.push({ part, error }) - const reason = normalizeReason(error) - await logError(ctx.client, buildInboundTranslationError(state.translate_user_lang, reason)) - const originalText = part.text - part.text = `${originalText}\n\n⚠️ Translation failed: ${reason}. Original text was sent to the model.` - part.ignored = true - nextParts.push( - createLlmOnlyTextPart(part.sessionID, part.messageID, originalText, { - translate_role: "llm_only_fallback", - translate_nonce: state.translate_nonce, - translate_part_index: eligibleIndex, - }), - ) - } -} - -async function processParts( - ctx: HookContext, - output: ChatMessageOutput, - state: TranslateState, -): Promise { - const result: PartProcessingResult = { nextParts: [], eligibleIndex: 0, errors: [] } - for (const part of output.parts as TextPartLike[]) { - result.nextParts.push(part) - if (!isUserAuthoredTextPart(part)) continue - result.firstUserTextPart ??= part - const currentEligibleIndex = result.eligibleIndex - result.eligibleIndex += 1 - if (part.text.trim().length === 0) continue - await translateUserPart(ctx, state, part, currentEligibleIndex, result.nextParts, result.errors) - } - return result -} - -function appendActivationBanner( - ctx: HookContext, - input: ChatMessageInput, - output: ChatMessageOutput, - state: TranslateState, - processed: PartProcessingResult, -) { - const bannerText = createActivationBannerText(ctx.options) - if (processed.firstUserTextPart !== undefined) { - processed.firstUserTextPart.text = `${processed.firstUserTextPart.text}\n\n${bannerText}` - } - processed.nextParts.push(createActivationBannerPart(input.sessionID, output.message.id, state, bannerText)) -} - -async function handleChatMessage(ctx: HookContext, input: ChatMessageInput, output: ChatMessageOutput) { - const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID) - const activation = await activateFromTrigger(ctx, input, output, resolved) - if (activation.aborted || !activation.state) return - - const processed = await processParts(ctx, output, activation.state) - if ( - activation.activatedThisTurn && - processed.errors.length > 0 && - processed.eligibleIndex === processed.errors.length - ) { - cacheSessionState(input.sessionID, INACTIVE_ROOT_SESSION) - return - } - - if (activation.activatedThisTurn) appendActivationBanner(ctx, input, output, activation.state, processed) - output.parts.splice(0, output.parts.length, ...(processed.nextParts as typeof output.parts)) -} - -export function createChatMessageHook(ctx: HookContext): ChatMessageHook { - return async (input, output) => { - try { - await handleChatMessage(ctx, input, output) - } catch (error) { - await logError(ctx.client, error) - } - } -} diff --git a/src/activation/index.ts b/src/activation/index.ts deleted file mode 100644 index 994cff2..0000000 --- a/src/activation/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin" -import { type PluginClientLike, resolveOptions } from "../constants" -import { createTranslator } from "../translator" -import { createChatMessageHook } from "./chat-message" -import { createMessagesTransformHook } from "./messages-transform" -import { createToolExecuteAfterHook, createToolExecuteBeforeHook, resetQuestionSnapshots } from "./question-hooks" -import { resetSessionStateCache } from "./state" -import { createTextCompleteHook } from "./text-complete" -import type { HookContext, HookDependencies } from "./types" - -export { extractStoredState } from "./state" -export { findTriggerMatch, stripTriggerKeyword } from "./trigger" - -export function __resetActivationCacheForTest() { - resetSessionStateCache() - resetQuestionSnapshots() -} - -export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks { - if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {} - - const client = ctx.client as PluginClientLike - const options = resolveOptions(rawOptions) - const hookContext: HookContext = { - client, - directory: ctx.directory, - options, - translator: deps.translator ?? createTranslator(client, options), - } - - return { - "chat.message": createChatMessageHook(hookContext), - "experimental.chat.messages.transform": createMessagesTransformHook(hookContext), - "experimental.text.complete": createTextCompleteHook(hookContext), - "tool.execute.before": createToolExecuteBeforeHook(hookContext), - "tool.execute.after": createToolExecuteAfterHook(hookContext), - } -} diff --git a/src/activation/logging.ts b/src/activation/logging.ts deleted file mode 100644 index ce3afbf..0000000 --- a/src/activation/logging.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { normalizeReason, PLUGIN_NAME, type PluginClientLike } from "../constants" - -export function logError(client: PluginClientLike, error: unknown) { - return client.app.log({ - body: { - service: PLUGIN_NAME, - level: "error", - message: normalizeReason(error), - }, - }) -} diff --git a/src/activation/messages-transform.ts b/src/activation/messages-transform.ts deleted file mode 100644 index d719930..0000000 --- a/src/activation/messages-transform.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Hooks } from "@opencode-ai/plugin" -import { isTextPart, type MessageWithPartsLike } from "../constants" -import { extractEnglishHistoryText } from "../formatting" -import { getDisplayLanguageLabel } from "../labels" -import { logError } from "./logging" -import { isTranslatedUserDisplayPart } from "./metadata" -import { resolveSessionState } from "./state" -import type { HookContext } from "./types" - -type MessagesTransformHook = NonNullable - -export function createMessagesTransformHook(ctx: HookContext): MessagesTransformHook { - return async (_input, output) => { - try { - const sessionID = output.messages[0]?.info.sessionID - if (!sessionID) return - - const resolved = await resolveSessionState(ctx.client, ctx.directory, sessionID) - const activeState = resolved.state - if (!activeState) return - - const extractContext = { - nonce: activeState.translate_nonce, - label: getDisplayLanguageLabel(activeState.translate_user_lang), - } - - for (const message of output.messages as MessageWithPartsLike[]) { - if (message.info.role === "user") { - for (const part of message.parts) { - if (isTranslatedUserDisplayPart(part)) part.ignored = true - } - continue - } - - if (message.info.role !== "assistant") continue - for (const part of message.parts) { - if (isTextPart(part)) part.text = extractEnglishHistoryText(part.text, extractContext) - } - } - } catch (error) { - await logError(ctx.client, error) - } - } -} diff --git a/src/activation/metadata.ts b/src/activation/metadata.ts deleted file mode 100644 index 3e02eb3..0000000 --- a/src/activation/metadata.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - isTextPart, - isTranslateStateRecord, - LLM_LANGUAGE, - type StoredTextMetadata, - type TextPartLike, - type TranslateState, -} from "../constants" -import { hashText } from "../translator" - -export function asMetadata(part: TextPartLike): StoredTextMetadata { - return (part.metadata ?? {}) as StoredTextMetadata -} - -export function extractStateFromMetadata(metadata: StoredTextMetadata | undefined): TranslateState | undefined { - if (!isTranslateStateRecord(metadata)) return undefined - return { - translate_enabled: true, - translate_user_lang: metadata.translate_user_lang, - translate_llm_lang: LLM_LANGUAGE, - translate_nonce: metadata.translate_nonce, - } -} - -export function mergeTranslatedMetadata( - state: TranslateState, - part: TextPartLike, - english: string, -): Record { - return { - ...(part.metadata ?? {}), - ...state, - translate_source_hash: hashText(part.text ?? ""), - translate_en: english, - } -} - -export function isTranslatedUserDisplayPart(part: TextPartLike): boolean { - if (!isTextPart(part) || part.synthetic === true) return false - return extractStateFromMetadata(asMetadata(part)) !== undefined -} diff --git a/src/activation/parts.ts b/src/activation/parts.ts deleted file mode 100644 index df823ae..0000000 --- a/src/activation/parts.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type ResolvedTranslateOptions, SPEC_VERSION, type TextPartLike, type TranslateState } from "../constants" -import { createSyntheticPartID } from "../translator" - -export function createActivationBannerText(options: ResolvedTranslateOptions): string { - return `✓ Translation mode enabled · model: ${options.model} · language: ${options.lang}` -} - -export function createLlmOnlyTextPart( - sessionID: string, - messageID: string, - text: string, - metadata: Record, -): TextPartLike { - return { - id: createSyntheticPartID(), - sessionID, - messageID, - type: "text", - text, - synthetic: true, - ignored: false, - metadata, - } -} - -export function createActivationBannerPart( - sessionID: string, - messageID: string, - state: TranslateState, - text: string, -): TextPartLike { - return { - id: createSyntheticPartID(), - sessionID, - messageID, - type: "text", - text, - synthetic: true, - ignored: true, - metadata: { - ...state, - translate_role: "activation_banner", - translate_spec_version: SPEC_VERSION, - }, - } -} diff --git a/src/activation/question-hooks.ts b/src/activation/question-hooks.ts deleted file mode 100644 index c4c2e95..0000000 --- a/src/activation/question-hooks.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { Hooks } from "@opencode-ai/plugin" -import { buildInboundTranslationError, LLM_LANGUAGE, normalizeReason } from "../constants" -import { - isQuestionArgs, - type QuestionSnapshot, - type QuestionToolOutput, - restoreQuestionArgs, - restoreQuestionOutput, - snapshotQuestions, - translateQuestionArgs, -} from "../question-tool" -import { logError } from "./logging" -import { resolveSessionState } from "./state" -import { type HookContext, QUESTION_TOOL_ID } from "./types" - -const QUESTION_SNAPSHOT_LIMIT = 1_000 - -const questionSnapshots = new Map() - -function pruneQuestionSnapshots() { - while (questionSnapshots.size > QUESTION_SNAPSHOT_LIMIT) { - for (const callID of questionSnapshots.keys()) { - questionSnapshots.delete(callID) - break - } - } -} - -export function resetQuestionSnapshots() { - questionSnapshots.clear() -} - -export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable { - return async (input, output) => { - try { - if (input.tool !== QUESTION_TOOL_ID) return - const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID) - const activeState = resolved.state - if (!activeState) return - - if (!isQuestionArgs(output.args)) return - const args = output.args - - const original = snapshotQuestions(args) - if (activeState.translate_user_lang !== LLM_LANGUAGE) { - try { - await translateQuestionArgs(args, (texts) => - ctx.translator.translateTexts - ? ctx.translator.translateTexts({ - texts, - sourceLanguage: LLM_LANGUAGE, - targetLanguage: activeState.translate_user_lang, - direction: "outbound", - }) - : Promise.all( - texts.map((text) => - ctx.translator.translateText({ - text, - sourceLanguage: LLM_LANGUAGE, - targetLanguage: activeState.translate_user_lang, - direction: "outbound", - }), - ), - ), - ) - } catch (error) { - args.questions.splice(0, args.questions.length, ...snapshotQuestions({ questions: original })) - await logError(ctx.client, error) - return - } - } - - questionSnapshots.set(input.callID, { - original, - translated: snapshotQuestions(args), - userLanguage: activeState.translate_user_lang, - }) - pruneQuestionSnapshots() - } catch (error) { - await logError(ctx.client, error) - } - } -} - -export function createToolExecuteAfterHook(ctx: HookContext): NonNullable { - return async (input, output) => { - try { - if (input.tool !== QUESTION_TOOL_ID) return - const snapshot = questionSnapshots.get(input.callID) - if (!snapshot) return - questionSnapshots.delete(input.callID) - if (isQuestionArgs(input.args)) restoreQuestionArgs(input.args, snapshot.original) - - if (snapshot.userLanguage === LLM_LANGUAGE) { - await restoreQuestionOutput(output as QuestionToolOutput, snapshot) - return - } - - await restoreQuestionOutput(output as QuestionToolOutput, snapshot, { - translateCustomAnswers: (texts: readonly string[]) => - ctx.translator.translateTexts - ? ctx.translator.translateTexts({ - texts, - sourceLanguage: snapshot.userLanguage, - targetLanguage: LLM_LANGUAGE, - direction: "inbound", - }) - : Promise.all( - texts.map((text) => - ctx.translator.translateText({ - text, - sourceLanguage: snapshot.userLanguage, - targetLanguage: LLM_LANGUAGE, - direction: "inbound", - }), - ), - ), - onTranslationError: async (error) => { - await logError(ctx.client, buildInboundTranslationError(snapshot.userLanguage, normalizeReason(error))) - }, - }) - } catch (error) { - await logError(ctx.client, error) - } - } -} diff --git a/src/activation/state.ts b/src/activation/state.ts deleted file mode 100644 index df40ef0..0000000 --- a/src/activation/state.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { randomBytes } from "node:crypto" -import { - isTextPart, - LLM_LANGUAGE, - type MessageWithPartsLike, - type PluginClientLike, - type ResolvedTranslateOptions, - type TranslateState, - unwrapData, -} from "../constants" -import { asMetadata, extractStateFromMetadata } from "./metadata" -import { - type CachedSessionState, - INACTIVE_CHILD_SESSION, - INACTIVE_ROOT_SESSION, - type ResolvedSessionState, -} from "./types" - -const sessionStateCache = new Map() - -export function resetSessionStateCache() { - sessionStateCache.clear() -} - -export function cacheSessionState(sessionID: string, state: CachedSessionState) { - sessionStateCache.set(sessionID, state) -} - -export function createState(options: ResolvedTranslateOptions): TranslateState { - return { - translate_enabled: true, - translate_user_lang: options.lang, - translate_llm_lang: LLM_LANGUAGE, - translate_nonce: randomBytes(16).toString("hex"), - } -} - -export function extractStoredState(messages: MessageWithPartsLike[]): TranslateState | undefined { - let fallback: TranslateState | undefined - - for (const message of messages) { - for (const part of message.parts) { - if (!isTextPart(part)) continue - const metadata = asMetadata(part) - const state = extractStateFromMetadata(metadata) - if (!state) continue - if (metadata.translate_role === "activation_banner") return state - if (message.info.role === "user" && part.synthetic !== true && fallback === undefined) fallback = state - } - } - - return fallback -} - -function cachedStateResult(cached: CachedSessionState): ResolvedSessionState { - if (cached === INACTIVE_ROOT_SESSION) return { sessionActive: false, canActivate: true, storedMessages: [] } - if (cached === INACTIVE_CHILD_SESSION) return { sessionActive: false, canActivate: false, storedMessages: [] } - return { sessionActive: true, canActivate: false, state: cached, storedMessages: [] } -} - -export async function resolveSessionState( - client: PluginClientLike, - directory: string | undefined, - sessionID: string, -): Promise { - const cached = sessionStateCache.get(sessionID) - if (cached !== undefined) return cachedStateResult(cached) - - const session = unwrapData( - await client.session.get({ - path: { id: sessionID }, - query: { ...(directory ? { directory } : {}) }, - throwOnError: true, - }), - ) - if (session.parentID != null) { - sessionStateCache.set(sessionID, INACTIVE_CHILD_SESSION) - return { sessionActive: false, canActivate: false, storedMessages: [] } - } - - const storedMessages = unwrapData( - await client.session.messages({ - path: { id: sessionID }, - query: { ...(directory ? { directory } : {}) }, - throwOnError: true, - }), - ) - const state = extractStoredState(storedMessages) - sessionStateCache.set(sessionID, state ?? INACTIVE_ROOT_SESSION) - - return { - sessionActive: Boolean(state), - canActivate: !state, - state: state ?? undefined, - storedMessages, - } -} diff --git a/src/activation/text-complete.ts b/src/activation/text-complete.ts deleted file mode 100644 index ad7a6fa..0000000 --- a/src/activation/text-complete.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Hooks } from "@opencode-ai/plugin" -import { LLM_LANGUAGE, type MessageWithPartsLike, unwrapData } from "../constants" -import { composeTranslatedAssistantText, composeTranslationFailureText } from "../formatting" -import { getDisplayLanguageLabel } from "../labels" -import { logError } from "./logging" -import { resolveSessionState } from "./state" -import type { HookContext } from "./types" - -type TextCompleteHook = NonNullable - -export function createTextCompleteHook(ctx: HookContext): TextCompleteHook { - return async (input, output) => { - try { - const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID) - const activeState = resolved.state - if (!activeState) return - - const message = unwrapData( - await ctx.client.session.message({ - path: { id: input.sessionID, messageID: input.messageID }, - query: { ...(ctx.directory ? { directory: ctx.directory } : {}) }, - throwOnError: true, - }), - ) as MessageWithPartsLike & { info: Record } - - if (message.info.role !== "assistant") return - if (message.info.summary === true) return - if (activeState.translate_user_lang === LLM_LANGUAGE || output.text.length === 0) return - - try { - const translated = await ctx.translator.translateText({ - text: output.text, - sourceLanguage: LLM_LANGUAGE, - targetLanguage: activeState.translate_user_lang, - direction: "outbound", - }) - output.text = composeTranslatedAssistantText( - output.text, - getDisplayLanguageLabel(activeState.translate_user_lang), - translated, - ) - } catch (error) { - output.text = composeTranslationFailureText(output.text) - await logError(ctx.client, error) - } - } catch (error) { - await logError(ctx.client, error) - } - } -} diff --git a/src/activation/trigger.ts b/src/activation/trigger.ts deleted file mode 100644 index 9364ad8..0000000 --- a/src/activation/trigger.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isUserAuthoredTextPart, type TextPartLike } from "../constants" -import type { TriggerMatch } from "./types" - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -export function findTriggerMatch(parts: TextPartLike[], trigger: string[]): TriggerMatch | undefined { - let eligibleIndex = 0 - for (let partArrayIndex = 0; partArrayIndex < parts.length; partArrayIndex += 1) { - const part = parts[partArrayIndex] - if (!isUserAuthoredTextPart(part)) continue - - let bestForPart: TriggerMatch | undefined - for (const keyword of trigger) { - const pattern = new RegExp(`(^|[ \\t\\r\\n\\f\\v])${escapeRegex(keyword)}(?=$|[ \\t\\r\\n\\f\\v])`) - const match = pattern.exec(part.text) - if (!match) continue - const offset = match.index + match[1].length - if (!bestForPart || offset < bestForPart.offset) bestForPart = { partArrayIndex, eligibleIndex, keyword, offset } - } - - if (bestForPart) return bestForPart - eligibleIndex += 1 - } - - return undefined -} - -export function stripTriggerKeyword(text: string, keyword: string, offset: number): string { - const lineStart = text.lastIndexOf("\n", offset - 1) + 1 - const nextNewline = text.indexOf("\n", offset) - const lineEnd = nextNewline === -1 ? text.length : nextNewline - const line = text.slice(lineStart, lineEnd) - const localOffset = offset - lineStart - - let rewrittenLine: string - if (localOffset === 0 && line.startsWith(`${keyword} `)) { - rewrittenLine = line.slice(keyword.length + 1) - } else if ( - localOffset + keyword.length === line.length && - localOffset > 0 && - line.slice(localOffset - 1, localOffset) === " " - ) { - rewrittenLine = line.slice(0, localOffset - 1) - } else if ( - localOffset > 0 && - line.slice(localOffset - 1, localOffset) === " " && - line.slice(localOffset + keyword.length, localOffset + keyword.length + 1) === " " - ) { - rewrittenLine = `${line.slice(0, localOffset - 1)} ${line.slice(localOffset + keyword.length + 1)}` - } else { - rewrittenLine = `${line.slice(0, localOffset)}${line.slice(localOffset + keyword.length)}` - } - - return `${text.slice(0, lineStart)}${rewrittenLine}${text.slice(lineEnd)}` -} diff --git a/src/activation/types.ts b/src/activation/types.ts deleted file mode 100644 index 56b24d0..0000000 --- a/src/activation/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { PluginClientLike, ResolvedTranslateOptions, TranslateState } from "../constants" - -export const INACTIVE_ROOT_SESSION = "inactive-root" -export const INACTIVE_CHILD_SESSION = "inactive-child" -export const QUESTION_TOOL_ID = "question" - -export type CachedSessionState = TranslateState | typeof INACTIVE_ROOT_SESSION | typeof INACTIVE_CHILD_SESSION - -export interface ResolvedSessionState { - sessionActive: boolean - canActivate: boolean - state?: TranslateState - storedMessages: import("../constants").MessageWithPartsLike[] -} - -export interface TriggerMatch { - partArrayIndex: number - eligibleIndex: number - keyword: string - offset: number -} - -interface TranslatorLike { - translateText(input: { - text: string - sourceLanguage: string - targetLanguage: string - direction: "inbound" | "outbound" - }): Promise - translateTexts?(input: { - texts: readonly string[] - sourceLanguage: string - targetLanguage: string - direction: "inbound" | "outbound" - }): Promise -} - -export interface HookDependencies { - translator?: TranslatorLike -} - -export interface HookContext { - client: PluginClientLike - directory?: string - options: ResolvedTranslateOptions - translator: TranslatorLike -} diff --git a/src/anthropic-oauth.ts b/src/anthropic-oauth.ts deleted file mode 100644 index aa2ccf1..0000000 --- a/src/anthropic-oauth.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Anthropic OAuth request transformations. -// -// When using Anthropic's OAuth credentials (Claude Pro/Max) outside of the -// official Claude Code client, the /v1/messages API responds with a -// `429 rate_limit_error` and an empty "Error" message unless the request -// shape matches the Claude Code CLI fingerprint: specific headers, a -// `?beta=true` query, the Claude Code identity in `system[0]`, and a -// deterministic billing header block. -// -// This module implements the minimum transformation that makes translator -// requests pass those checks. The technique (including the identity string, -// required beta headers, and CCH billing header format) is documented by the -// `@ex-machina/opencode-anthropic-auth` plugin: -// -// https://github.com/ex-machina-co/opencode-anthropic-auth -// -// We only apply these transformations to translator requests this plugin -// originates. OpenCode's main chat already has its own auth loader (e.g. -// `@ex-machina/opencode-anthropic-auth`) handling its requests independently. - -import { createHash } from "node:crypto" - -export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK." - -const REQUIRED_BETAS = ["oauth-2025-04-20", "interleaved-thinking-2025-05-14"] as const - -const CLAUDE_CODE_VERSION = "2.1.87" -const CLAUDE_CODE_ENTRYPOINT = "sdk-cli" -export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)` - -// Deterministic billing header parameters — must match Claude Code's own derivation. -const CCH_SALT = "59cf53e54c78" -const CCH_POSITIONS = [4, 7, 20] as const - -type SystemBlock = { type: string; text: string; [key: string]: unknown } - -type MessageLike = { - role?: string - content?: string | Array<{ type?: string; text?: string }> -} - -function isRecord(value: unknown): value is Record { - return value != null && typeof value === "object" && !Array.isArray(value) -} - -function extractFirstUserMessageText(messages: MessageLike[] | undefined): string { - if (!Array.isArray(messages)) return "" - const first = messages.find((message) => message?.role === "user") - if (!first) return "" - const { content } = first - if (typeof content === "string") return content - if (Array.isArray(content)) { - const textBlock = content.find((block) => block?.type === "text") - if (textBlock?.text) return textBlock.text - } - return "" -} - -function computeCCH(messageText: string): string { - return createHash("sha256").update(messageText).digest("hex").slice(0, 5) -} - -function computeVersionSuffix(messageText: string, version: string): string { - const chars = CCH_POSITIONS.map((index) => messageText[index] ?? "0").join("") - return createHash("sha256").update(`${CCH_SALT}${chars}${version}`).digest("hex").slice(0, 3) -} - -export function buildBillingHeaderValue(messages: MessageLike[] | undefined): string { - const text = extractFirstUserMessageText(messages) - const cch = computeCCH(text) - const suffix = computeVersionSuffix(text, CLAUDE_CODE_VERSION) - return ( - "x-anthropic-billing-header: " + - `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` + - `cc_entrypoint=${CLAUDE_CODE_ENTRYPOINT}; ` + - `cch=${cch};` - ) -} - -export function mergeBetaHeaders(headers: Headers): string { - const incoming = headers.get("anthropic-beta") || "" - const incomingList = incoming - .split(",") - .map((value) => value.trim()) - .filter(Boolean) - return [...new Set([...REQUIRED_BETAS, ...incomingList])].join(",") -} - -export function setOAuthHeaders(headers: Headers, accessToken: string): Headers { - headers.set("authorization", `Bearer ${accessToken}`) - headers.set("anthropic-beta", mergeBetaHeaders(headers)) - headers.set("user-agent", CLAUDE_CLI_USER_AGENT) - headers.delete("x-api-key") - return headers -} - -export function rewriteMessagesURL(input: URL): URL { - if (input.pathname === "/v1/messages" && !input.searchParams.has("beta")) { - input.searchParams.set("beta", "true") - } - return input -} - -function normalizeSystem(raw: unknown): SystemBlock[] { - if (raw == null) return [] - if (typeof raw === "string") return raw.length > 0 ? [{ type: "text", text: raw }] : [] - if (isRecord(raw)) { - const type = typeof raw.type === "string" ? raw.type : "text" - const text = typeof raw.text === "string" ? raw.text : "" - return [{ ...raw, type, text }] - } - if (!Array.isArray(raw)) return [] - return raw - .map((item): SystemBlock | null => { - if (typeof item === "string") return { type: "text", text: item } - if (isRecord(item) && typeof item.text === "string") { - const type = typeof item.type === "string" ? item.type : "text" - return { ...item, type, text: item.text } - } - return null - }) - .filter((block): block is SystemBlock => block !== null) -} - -export function buildOAuthSystem(rawSystem: unknown, messages: MessageLike[] | undefined): SystemBlock[] { - const identity: SystemBlock = { type: "text", text: CLAUDE_CODE_IDENTITY } - const existing = normalizeSystem(rawSystem).filter((block) => block.text !== CLAUDE_CODE_IDENTITY) - const billing: SystemBlock = { type: "text", text: buildBillingHeaderValue(messages) } - return [billing, identity, ...existing] -} - -// Rewrite an /v1/messages POST body so its system prompt and billing header -// satisfy Claude Code's OAuth fingerprint. Returns the original body on parse -// failure so we never break a request that was already well-formed. -export function rewriteMessagesBody(body: string): string { - try { - const parsed = JSON.parse(body) as Record - const messages = Array.isArray(parsed.messages) ? (parsed.messages as MessageLike[]) : undefined - parsed.system = buildOAuthSystem(parsed.system, messages) - return JSON.stringify(parsed) - } catch { - return body - } -} - -export function isAnthropicMessagesRequest(url: URL): boolean { - return url.pathname === "/v1/messages" -} diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index f18ba02..0000000 --- a/src/auth.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./auth/index" diff --git a/src/auth/codex-request.ts b/src/auth/codex-request.ts deleted file mode 100644 index a6f4789..0000000 --- a/src/auth/codex-request.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { isRecord } from "./codex-shared" -import type { CodexBodyRewrite } from "./types" - -function textFromContent(content: unknown): string | undefined { - if (typeof content === "string") return content - if (!Array.isArray(content)) return undefined - - const text = content - .map((part) => (isRecord(part) && typeof part.text === "string" ? part.text : undefined)) - .filter((value): value is string => value !== undefined) - .join("\n") - - return text || undefined -} - -function normalizeCodexContent(role: string, content: unknown): Record[] { - const textType = role === "assistant" ? "output_text" : "input_text" - if (typeof content === "string") return [{ type: textType, text: content }] - if (!Array.isArray(content)) return [] - - const result: Record[] = [] - for (const part of content) { - if (!isRecord(part)) continue - const type = part.type - if (type === "input_text" || type === "output_text") { - result.push({ ...part, type: textType }) - continue - } - if (type === "input_image") { - result.push({ ...part }) - continue - } - if (typeof part.text === "string") result.push({ type: textType, text: part.text }) - } - - return result -} - -function normalizeCodexInputItem(item: unknown, instructions: string[]): unknown | undefined { - if (!isRecord(item)) return item - const role = typeof item.role === "string" ? item.role : undefined - - if (role === "system" || role === "developer") { - const text = textFromContent(item.content) - if (text) instructions.push(text) - return undefined - } - - if (item.type === "message" && role) { - const content = normalizeCodexContent(role, item.content) - return content.length > 0 ? { ...item, role, content } : undefined - } - - if (role) { - const content = normalizeCodexContent(role, item.content) - return content.length > 0 ? { type: "message", role, content } : undefined - } - - return item -} - -export function rewriteOpenAICodexBody(body: BodyInit | null | undefined): CodexBodyRewrite { - if (typeof body !== "string") return { body, originalStream: false } - - let parsed: unknown - try { - parsed = JSON.parse(body) - } catch { - return { body, originalStream: false } - } - - if (!isRecord(parsed)) return { body, originalStream: false } - const originalStream = parsed.stream === true - const sourceInput = Array.isArray(parsed.input) - ? parsed.input - : Array.isArray(parsed.messages) - ? parsed.messages - : undefined - if (!sourceInput) return { body, originalStream } - - const instructions: string[] = [] - if (typeof parsed.instructions === "string" && parsed.instructions) instructions.push(parsed.instructions) - const input = sourceInput - .map((item) => normalizeCodexInputItem(item, instructions)) - .filter((item): item is unknown => item !== undefined) - const include = Array.isArray(parsed.include) - ? parsed.include.filter((item): item is string => typeof item === "string") - : [] - if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content") - - return { - body: JSON.stringify({ - ...parsed, - instructions: instructions.join("\n\n"), - input, - tools: Array.isArray(parsed.tools) ? parsed.tools : [], - tool_choice: typeof parsed.tool_choice === "string" ? parsed.tool_choice : "auto", - parallel_tool_calls: typeof parsed.parallel_tool_calls === "boolean" ? parsed.parallel_tool_calls : false, - store: false, - stream: true, - include, - max_output_tokens: undefined, - max_completion_tokens: undefined, - messages: undefined, - }), - originalStream, - } -} diff --git a/src/auth/codex-response.ts b/src/auth/codex-response.ts deleted file mode 100644 index 0364af2..0000000 --- a/src/auth/codex-response.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isRecord } from "./codex-shared" - -function normalizeCodexOutputItem(item: unknown, index: number): unknown | undefined { - if (!isRecord(item)) return undefined - if (item.type !== "message" || item.role !== "assistant") return item - if (!Array.isArray(item.content)) return undefined - - const content: Record[] = [] - for (const part of item.content) { - if (!isRecord(part) || part.type !== "output_text" || typeof part.text !== "string") continue - content.push({ ...part, annotations: Array.isArray(part.annotations) ? part.annotations : [] }) - } - - if (content.length === 0) return undefined - return { - ...item, - id: typeof item.id === "string" ? item.id : `msg_opencode_translate_${index}`, - role: "assistant", - content, - } -} - -function buildCodexTextOutput(text: string): Record { - return { - type: "message", - id: "msg_opencode_translate_0", - role: "assistant", - content: [{ type: "output_text", text, annotations: [] }], - } -} - -function parseCodexSSEResponse(text: string): unknown | undefined { - let finalResponse: unknown - let deltaText = "" - const outputItems: unknown[] = [] - - for (const line of text.split(/\r?\n/)) { - if (!line.startsWith("data: ")) continue - const payload = line.slice(6).trim() - if (!payload || payload === "[DONE]") continue - try { - const parsed = JSON.parse(payload) as Record - if (parsed.type === "response.output_text.delta" && typeof parsed.delta === "string") { - deltaText += parsed.delta - } else if ( - (parsed.type === "response.output_item.done" || parsed.type === "response.output_item.added") && - parsed.item - ) { - outputItems.push(parsed.item) - } else if ((parsed.type === "response.done" || parsed.type === "response.completed") && parsed.response) { - finalResponse = parsed.response - } - } catch {} - } - - if (!finalResponse && !deltaText && outputItems.length === 0) return undefined - const response: Record = isRecord(finalResponse) - ? { ...finalResponse } - : { id: "resp_opencode_translate" } - const existingOutput: unknown[] = Array.isArray(response.output) ? response.output : [] - const sourceOutput = existingOutput.length > 0 ? existingOutput : outputItems - const normalizedOutput = sourceOutput - .map((item, index) => normalizeCodexOutputItem(item, index)) - .filter((item): item is unknown => item !== undefined) - - response.output = normalizedOutput.length > 0 ? normalizedOutput : deltaText ? [buildCodexTextOutput(deltaText)] : [] - return response -} - -export async function convertCodexSSEToJSON(response: Response): Promise { - const headers = new Headers(response.headers) - const text = await response.text() - const parsed = parseCodexSSEResponse(text) - if (!parsed) return new Response(text, { status: response.status, statusText: response.statusText, headers }) - - headers.set("content-type", "application/json; charset=utf-8") - return new Response(JSON.stringify(parsed), { status: response.status, statusText: response.statusText, headers }) -} diff --git a/src/auth/codex-shared.ts b/src/auth/codex-shared.ts deleted file mode 100644 index 4959274..0000000 --- a/src/auth/codex-shared.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) -} diff --git a/src/auth/headers.ts b/src/auth/headers.ts deleted file mode 100644 index 81ce04e..0000000 --- a/src/auth/headers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { USER_AGENT } from "../constants" - -export function copyHeaders(headers?: HeadersInit): Headers { - return new Headers(headers) -} - -export function headerValue(headers: Headers, key: string): string | undefined { - const value = headers.get(key) - return value === null ? undefined : value -} - -export function packageUserAgent(packageVersion?: string): string { - return packageVersion ? USER_AGENT.replace("0.0.0", packageVersion) : USER_AGENT -} - -export function setUserAgent(headers: Headers, packageVersion?: string) { - headers.set("User-Agent", packageUserAgent(packageVersion)) -} diff --git a/src/auth/index.ts b/src/auth/index.ts deleted file mode 100644 index ed14803..0000000 --- a/src/auth/index.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { setTimeout as sleep } from "node:timers/promises" -import { - AUTH_ENV_FALLBACK, - buildAuthUnavailableError, - buildOAuthRefreshError, - getEnvVarHint, - normalizeReason, - OAUTH_DUMMY_KEY, - type OAuthInfo, - type PluginClientLike, - type ProviderInfo, - parseTranslatorModel, - unwrapData, -} from "../constants" -import { buildOAuthFetch } from "./oauth-fetch" -import { refreshAnthropic, refreshOpenAI } from "./refresh" -import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "./store" -import type { AuthDependencies, AuthRuntime, ResolvedCredential } from "./types" - -export function __resetAuthCachesForTest() { - // Resolver instances own their caches; this remains as a stable test helper. -} - -function isMissingCredentialError(error: unknown): boolean { - const message = normalizeReason(error).toLowerCase() - return ( - message.includes("api key") || - message.includes("api-key") || - message.includes("missing credentials") || - message.includes("missing authentication") || - message.includes("missing auth") || - message.includes("no auth") - ) -} - -function hasOAuthRequestAdapter(providerID: string): boolean { - return providerID === "anthropic" || providerID === "openai" || providerID === "github-copilot" -} - -async function refreshProviderOAuth( - providerID: string, - info: OAuthInfo, - client: PluginClientLike, - runtime: AuthRuntime, -) { - let refreshed: OAuthInfo - try { - if (providerID === "anthropic") refreshed = await refreshAnthropic(info, runtime) - else if (providerID === "openai") refreshed = await refreshOpenAI(info, runtime) - else return info - } catch (error) { - if (error instanceof Error && error.message.includes(":OAUTH_REFRESH_FAILED]")) throw error - throw buildOAuthRefreshError(providerID, normalizeReason(error)) - } - - await client.auth.set({ path: { id: providerID }, body: refreshed }) - return refreshed -} - -async function getProvider(client: PluginClientLike, providerID: string): Promise { - try { - const listed = unwrapData(await client.provider.list({ throwOnError: true })) - return listed.all.find((provider) => provider.id === providerID) - } catch { - return undefined - } -} - -export function createCredentialResolver(client: PluginClientLike, deps: AuthDependencies = {}) { - const credentialCache = new Map() - const oauthRefreshInflight = new Map>() - const runtime: AuthRuntime = { - fetchImpl: deps.fetchImpl ?? fetch, - sleep: deps.sleep ?? ((ms: number) => sleep(ms)), - } - const now = deps.now ?? (() => Date.now()) - - async function resolveOAuth(providerID: string): Promise { - const authMap = await readAuthMap(deps) - const info = ensureOAuthInfo(authMap?.[providerID]) - if (!info) return undefined - if (info.expires >= now() + 60_000) return info - - const inflightKey = `${providerID}:${info.refresh}` - const existing = oauthRefreshInflight.get(inflightKey) - if (existing) return existing - - const refreshPromise = refreshProviderOAuth(providerID, info, client, runtime).finally(() => { - oauthRefreshInflight.delete(inflightKey) - }) - oauthRefreshInflight.set(inflightKey, refreshPromise) - return refreshPromise - } - - async function resolveAuthInfo(providerID: string) { - return (await readAuthMap(deps))?.[providerID] - } - - function credentialFromOAuth( - providerID: string, - provider: ProviderInfo | undefined, - authInfo: OAuthInfo, - ): ResolvedCredential { - return { - providerID, - provider, - authInfo, - apiKey: "", - fetch: buildOAuthFetch({ ...runtime, providerID, resolveOAuth, packageVersion: deps.packageVersion }), - mode: "oauth", - } - } - - async function resolve(providerModel: string): Promise { - const { providerID } = parseTranslatorModel(providerModel) - const cached = credentialCache.get(providerID) - if (cached && providerID !== "openai") return cached - - const provider = await getProvider(client, providerID) - const authInfo = await resolveAuthInfo(providerID) - if (providerID === "openai" && authInfo?.type === "oauth") { - const oauthInfo = await resolveOAuth(providerID) - if (oauthInfo) { - const resolved = credentialFromOAuth(providerID, provider, oauthInfo) - credentialCache.set(providerID, resolved) - return resolved - } - } - if (cached && !(providerID === "openai" && cached.mode === "oauth" && authInfo?.type !== "oauth")) return cached - - const providerKey = normalizeProviderKey(provider?.key) - if (providerKey) { - const resolved = { providerID, provider, authInfo, apiKey: providerKey, mode: "apiKey" as const } - credentialCache.set(providerID, resolved) - return resolved - } - - if (authInfo?.type === "api" && authInfo.key) { - const resolved = { providerID, provider, authInfo, apiKey: authInfo.key, mode: "apiKey" as const } - credentialCache.set(providerID, resolved) - return resolved - } - - if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY || hasOAuthRequestAdapter(providerID)) { - const oauthInfo = await resolveOAuth(providerID) - if (oauthInfo) { - const resolved = credentialFromOAuth(providerID, provider, oauthInfo) - credentialCache.set(providerID, resolved) - return resolved - } - } - - if (authInfo?.type === "oauth" && authInfo.access && provider?.options?.apiKey === undefined) { - const resolved = { providerID, provider, authInfo, apiKey: authInfo.access, mode: "oauth" as const } - credentialCache.set(providerID, resolved) - return resolved - } - - if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) { - const resolved = { providerID, provider, authInfo, mode: "default" as const } - credentialCache.set(providerID, resolved) - return resolved - } - - return { providerID, provider, authInfo, mode: "default" } - } - - return { - resolve, - authUnavailable: (providerID: string, provider?: ProviderInfo) => - buildAuthUnavailableError(providerID, getEnvVarHint(provider)), - isMissingCredentialError, - envFallback: AUTH_ENV_FALLBACK, - } -} - -export type { AuthDependencies, ResolvedCredential } diff --git a/src/auth/oauth-fetch.ts b/src/auth/oauth-fetch.ts deleted file mode 100644 index 4b085ef..0000000 --- a/src/auth/oauth-fetch.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - isAnthropicMessagesRequest, - rewriteMessagesBody, - rewriteMessagesURL, - setOAuthHeaders as setAnthropicOAuthHeaders, -} from "../anthropic-oauth" -import type { FetchLike, OAuthInfo } from "../constants" -import { rewriteOpenAICodexBody } from "./codex-request" -import { convertCodexSSEToJSON } from "./codex-response" -import { copyHeaders, packageUserAgent, setUserAgent } from "./headers" -import { exchangeCopilotToken } from "./refresh" -import type { AuthRuntime, OAuthResolver } from "./types" - -interface OAuthFetchOptions extends AuthRuntime { - providerID: string - resolveOAuth: OAuthResolver - packageVersion?: string -} - -interface RequestState { - headers: Headers - inputUrl: URL - body: BodyInit | null | undefined - convertCodexResponse: boolean -} - -function applyAnthropicRequest(state: RequestState, info: OAuthInfo) { - setAnthropicOAuthHeaders(state.headers, info.access) - state.headers.set("anthropic-version", "2023-06-01") - rewriteMessagesURL(state.inputUrl) - if (isAnthropicMessagesRequest(state.inputUrl) && typeof state.body === "string") { - state.body = rewriteMessagesBody(state.body) - } -} - -function applyOpenAIRequest(state: RequestState, info: OAuthInfo) { - state.headers.set("Authorization", `Bearer ${info.access}`) - if (info.accountId) state.headers.set("ChatGPT-Account-Id", info.accountId) - if ( - state.inputUrl.hostname !== "api.openai.com" || - (state.inputUrl.pathname !== "/v1/chat/completions" && state.inputUrl.pathname !== "/v1/responses") - ) { - return - } - - const rewritten = rewriteOpenAICodexBody(state.body) - state.inputUrl.protocol = "https:" - state.inputUrl.hostname = "chatgpt.com" - state.inputUrl.pathname = "/backend-api/codex/responses" - state.inputUrl.search = "" - state.body = rewritten.body - state.convertCodexResponse = !rewritten.originalStream - state.headers.set("OpenAI-Beta", "responses=experimental") - state.headers.set("originator", "codex_cli_rs") - state.headers.set("accept", "text/event-stream") - state.headers.delete("content-length") -} - -async function applyCopilotRequest(state: RequestState, info: OAuthInfo, options: OAuthFetchOptions) { - const session = await exchangeCopilotToken(info, options) - state.headers.set("Authorization", `Bearer ${session.token}`) - state.headers.set("Editor-Version", packageUserAgent(options.packageVersion)) - state.headers.set("Editor-Plugin-Version", packageUserAgent(options.packageVersion)) - state.headers.set("Copilot-Integration-Id", "vscode-chat") - state.headers.delete("x-api-key") - - if (info.enterpriseUrl) { - const target = new URL(info.enterpriseUrl.includes("://") ? info.enterpriseUrl : `https://${info.enterpriseUrl}`) - state.inputUrl.protocol = target.protocol - state.inputUrl.hostname = target.hostname - state.inputUrl.port = target.port - } -} - -function inputToURL(input: RequestInfo | URL): URL { - return input instanceof URL ? new URL(input.href) : new URL(typeof input === "string" ? input : input.url) -} - -export function buildOAuthFetch(options: OAuthFetchOptions): FetchLike { - return async (input, init) => { - const info = await options.resolveOAuth(options.providerID) - if (!info) return options.fetchImpl(input, init) - - const state: RequestState = { - headers: copyHeaders(init?.headers), - inputUrl: inputToURL(input), - body: init?.body, - convertCodexResponse: false, - } - setUserAgent(state.headers, options.packageVersion) - - if (options.providerID === "anthropic") applyAnthropicRequest(state, info) - if (options.providerID === "openai") applyOpenAIRequest(state, info) - if (options.providerID === "github-copilot") await applyCopilotRequest(state, info, options) - - const response = await options.fetchImpl(state.inputUrl, { ...init, headers: state.headers, body: state.body }) - if (state.convertCodexResponse && response.ok) return convertCodexSSEToJSON(response) - return response - } -} diff --git a/src/auth/refresh.ts b/src/auth/refresh.ts deleted file mode 100644 index d8c9c67..0000000 --- a/src/auth/refresh.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { buildOAuthRefreshError, normalizeReason, type OAuthInfo } from "../constants" -import { withRetry } from "./retry" -import type { AuthRuntime } from "./types" - -async function postOAuthToken(url: string, init: RequestInit, deps: AuthRuntime): Promise { - return withRetry( - () => - deps.fetchImpl(url, init).then(async (result) => { - if (!result.ok) { - const error = new Error(`HTTP ${result.status}`) as Error & { response?: Response; status?: number } - error.response = result - error.status = result.status - throw error - } - return result - }), - deps, - ) -} - -export async function refreshAnthropic(info: OAuthInfo, deps: AuthRuntime): Promise { - const response = await postOAuthToken( - "https://console.anthropic.com/v1/oauth/token", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: info.refresh, - client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", - }), - }, - deps, - ) - - let body: Record - try { - body = (await response.json()) as Record - } catch (error) { - throw buildOAuthRefreshError("anthropic", normalizeReason(error)) - } - - if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") { - throw buildOAuthRefreshError("anthropic", "Invalid token response") - } - - return { - type: "oauth", - access: body.access_token, - refresh: body.refresh_token, - expires: Date.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000, - accountId: info.accountId, - enterpriseUrl: info.enterpriseUrl, - } -} - -export async function refreshOpenAI(info: OAuthInfo, deps: AuthRuntime): Promise { - const response = await postOAuthToken( - "https://auth.openai.com/oauth/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: info.refresh, - client_id: "app_EMoamEEZ73f0CkXaXp7hrann", - }), - }, - deps, - ) - - let parsed: Record - try { - parsed = (await response.json()) as Record - } catch (error) { - throw buildOAuthRefreshError("openai", normalizeReason(error)) - } - - if (typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string") { - throw buildOAuthRefreshError("openai", "Invalid token response") - } - - return { - type: "oauth", - access: parsed.access_token, - refresh: parsed.refresh_token, - expires: Date.now() + (typeof parsed.expires_in === "number" ? parsed.expires_in : 3600) * 1000, - accountId: info.accountId, - enterpriseUrl: info.enterpriseUrl, - } -} - -export async function exchangeCopilotToken(info: OAuthInfo, deps: AuthRuntime): Promise<{ token: string }> { - const response = await postOAuthToken( - "https://api.github.com/copilot_internal/v2/token", - { method: "GET", headers: { Authorization: `token ${info.refresh}` } }, - deps, - ) - const parsed = (await response.json()) as Record - if (typeof parsed.token !== "string") throw buildOAuthRefreshError("github-copilot", "Invalid token response") - return { token: parsed.token } -} diff --git a/src/auth/retry.ts b/src/auth/retry.ts deleted file mode 100644 index fcd024a..0000000 --- a/src/auth/retry.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { normalizeReason } from "../constants" -import { headerValue } from "./headers" -import type { AuthDependencies } from "./types" - -function getStatus(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined - const record = error as Record - if (typeof record.status === "number") return record.status - if (typeof record.statusCode === "number") return record.statusCode - const response = record.response - if (response && typeof response === "object") { - const maybeStatus = (response as Record).status - if (typeof maybeStatus === "number") return maybeStatus - } - return undefined -} - -function getRetryAfterMs(error: unknown): number { - if (!error || typeof error !== "object") return 2000 - const response = (error as Record).response - if (response && typeof response === "object") { - const headers = (response as { headers?: Headers }).headers - if (headers instanceof Headers) { - const retryAfter = headerValue(headers, "retry-after") - if (!retryAfter) return 2000 - const seconds = Number(retryAfter) - if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) - const date = Date.parse(retryAfter) - if (Number.isFinite(date)) return Math.max(0, date - Date.now()) - } - } - return 2000 -} - -function isRetryableError(error: unknown): boolean { - const status = getStatus(error) - if (status === 429) return true - if (status !== undefined) return status >= 500 - const message = normalizeReason(error).toLowerCase() - return ( - message.includes("network") || - message.includes("fetch") || - message.includes("timeout") || - message.includes("socket") || - message.includes("econn") - ) -} - -export async function withRetry( - task: () => Promise, - deps: Required>, -): Promise { - let lastError: unknown - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - return await task() - } catch (error) { - lastError = error - if (!isRetryableError(error)) throw error - if (getStatus(error) === 429) { - if (attempt >= 1) throw error - await deps.sleep(getRetryAfterMs(error)) - continue - } - if (attempt >= 2) throw error - await deps.sleep(attempt === 0 ? 500 : 1500) - } - } - throw lastError -} diff --git a/src/auth/store.ts b/src/auth/store.ts deleted file mode 100644 index e2de4b5..0000000 --- a/src/auth/store.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { readFile } from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { type AuthInfo, OAUTH_DUMMY_KEY, type OAuthInfo } from "../constants" -import type { AuthDependencies } from "./types" - -// Mirrors opencode's xdg-basedir data location; see packages/core/src/global.ts. -function dataHome(): string { - const xdgDataHome = process.env.XDG_DATA_HOME - if (xdgDataHome) return xdgDataHome - return path.join(os.homedir(), ".local", "share") -} - -function authFilePaths(): string[] { - const root = path.join(dataHome(), "opencode") - return [path.join(root, "auth.json"), path.join(root, "auth-v2.json")] -} - -export function normalizeProviderKey(value: string | undefined): string | undefined { - if (!value || value === OAUTH_DUMMY_KEY) return undefined - return value -} - -export function ensureOAuthInfo(value: AuthInfo | undefined): OAuthInfo | undefined { - return value && value.type === "oauth" ? value : undefined -} - -function isAuthInfo(value: unknown): value is AuthInfo { - if (!value || typeof value !== "object" || Array.isArray(value)) return false - const record = value as Record - if (record.type === "api") return typeof record.key === "string" - if (record.type === "oauth") { - return typeof record.access === "string" && typeof record.refresh === "string" && typeof record.expires === "number" - } - if (record.type === "wellknown") return typeof record.key === "string" && typeof record.token === "string" - return false -} - -function normalizeAuthMap(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined - const record = raw as Record - - if ( - record.version === 2 && - record.accounts && - typeof record.accounts === "object" && - !Array.isArray(record.accounts) - ) { - const accounts = record.accounts as Record - const active = - record.active && typeof record.active === "object" && !Array.isArray(record.active) ? record.active : {} - const result: Record = {} - - for (const [serviceID, accountID] of Object.entries(active as Record)) { - if (typeof accountID !== "string") continue - const account = accounts[accountID] - if (!account || typeof account !== "object" || Array.isArray(account)) continue - const credential = (account as Record).credential - if (isAuthInfo(credential)) result[serviceID] = credential - } - - for (const account of Object.values(accounts)) { - if (!account || typeof account !== "object" || Array.isArray(account)) continue - const accountRecord = account as Record - const serviceID = accountRecord.serviceID - const credential = accountRecord.credential - if (typeof serviceID === "string" && result[serviceID] === undefined && isAuthInfo(credential)) { - result[serviceID] = credential - } - } - - return result - } - - const result: Record = {} - for (const [providerID, info] of Object.entries(record)) { - if (isAuthInfo(info)) result[providerID] = info - } - return result -} - -export async function readAuthMap(deps: AuthDependencies): Promise | undefined> { - if (process.env.OPENCODE_AUTH_CONTENT) { - try { - return normalizeAuthMap(JSON.parse(process.env.OPENCODE_AUTH_CONTENT)) - } catch {} - return undefined - } - - for (const filePath of authFilePaths()) { - try { - const raw = await (deps.readFile ?? readFile)(filePath, "utf8") - const parsed = normalizeAuthMap(JSON.parse(raw)) - if (parsed) return parsed - } catch {} - } - return undefined -} diff --git a/src/auth/types.ts b/src/auth/types.ts deleted file mode 100644 index 1490164..0000000 --- a/src/auth/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AuthInfo, FetchLike, OAuthInfo, ProviderInfo } from "../constants" - -export interface ResolvedCredential { - providerID: string - provider?: ProviderInfo - authInfo?: AuthInfo - apiKey?: string - fetch?: FetchLike - mode: "apiKey" | "oauth" | "default" -} - -export interface AuthDependencies { - fetchImpl?: FetchLike - now?: () => number - sleep?: (ms: number) => Promise - readFile?: (filePath: string, encoding: BufferEncoding) => Promise - packageVersion?: string -} - -export type AuthRuntime = Required> - -export type OAuthResolver = (providerID: string) => Promise - -export interface CodexBodyRewrite { - body: BodyInit | null | undefined - originalStream: boolean -} diff --git a/src/constants.ts b/src/constants.ts index 763e6ec..2db37b9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,3 @@ -export * from "./constants/errors" -export * from "./constants/guards" export * from "./constants/options" export * from "./constants/plugin" export * from "./constants/types" diff --git a/src/constants/errors.ts b/src/constants/errors.ts deleted file mode 100644 index 929089d..0000000 --- a/src/constants/errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { PLUGIN_NAME } from "./plugin" - -export function normalizeReason(error: unknown): string { - const raw = error instanceof Error ? error.message : String(error) - return raw.split(/\r?\n/, 1)[0].trim().slice(0, 200) -} - -export function buildInboundTranslationError(userLanguage: string, reason: string): Error { - return new Error( - `[${PLUGIN_NAME}:INBOUND_TRANSLATION_FAILED] Failed to translate user message from ${userLanguage} to English: ${reason}`, - ) -} - -export function buildAuthUnavailableError(providerID: string, envVar: string): Error { - return new Error( - `[${PLUGIN_NAME}:AUTH_UNAVAILABLE] No credential found for provider "${providerID}". Set ${envVar} in the environment or run "opencode auth login ${providerID}".`, - ) -} - -export function buildOAuthRefreshError(providerID: string, reason: string): Error { - return new Error( - `[${PLUGIN_NAME}:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "${providerID}": ${reason}. Re-authenticate with "opencode auth login ${providerID}".`, - ) -} diff --git a/src/constants/guards.ts b/src/constants/guards.ts deleted file mode 100644 index 4cc65f9..0000000 --- a/src/constants/guards.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { LLM_LANGUAGE, NONCE_PATTERN } from "./plugin" -import type { SDKResponseLike, TextPartLike, TranslateState } from "./types" - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0 -} - -export function unwrapData(value: T | SDKResponseLike): T { - if (value && typeof value === "object" && "data" in value && (value as SDKResponseLike).data !== undefined) { - return (value as SDKResponseLike).data as T - } - return value as T -} - -export function isTranslateStateRecord(value: unknown): value is TranslateState { - if (!value || typeof value !== "object") return false - const record = value as Record - return ( - record.translate_enabled === true && - record.translate_llm_lang === LLM_LANGUAGE && - isNonEmptyString(record.translate_user_lang) && - isNonEmptyString(record.translate_nonce) && - NONCE_PATTERN.test(record.translate_nonce) - ) -} - -export function isTextPart(part: TextPartLike): part is TextPartLike & { text: string } { - return part.type === "text" && typeof part.text === "string" -} - -export function isUserAuthoredTextPart(part: TextPartLike): part is TextPartLike & { text: string } { - return isTextPart(part) && part.synthetic !== true && part.ignored !== true -} diff --git a/src/constants/options.ts b/src/constants/options.ts index 3bdb744..f3f5cc5 100644 --- a/src/constants/options.ts +++ b/src/constants/options.ts @@ -1,5 +1,5 @@ -import { AUTH_ENV_FALLBACK, DEFAULT_TRIGGER, PLUGIN_NAME } from "./plugin" -import type { ProviderInfo, ResolvedTranslateOptions } from "./types" +import { DEFAULT_TRIGGER, PLUGIN_NAME } from "./plugin" +import type { ResolvedTranslateOptions } from "./types" export function resolveOptions(options: Record): ResolvedTranslateOptions { const model = typeof options.model === "string" ? options.model.trim() : "" @@ -39,10 +39,6 @@ export function resolveOptions(options: Record): ResolvedTransl } } -export function getEnvVarHint(provider: ProviderInfo | undefined): string { - return provider?.env[0] || AUTH_ENV_FALLBACK -} - export function parseTranslatorModel(model: string): { providerID: string; modelID: string } { const slash = model.indexOf("/") if (slash < 1 || slash === model.length - 1) { diff --git a/src/constants/plugin.ts b/src/constants/plugin.ts index 0936c29..03a8dc5 100644 --- a/src/constants/plugin.ts +++ b/src/constants/plugin.ts @@ -1,9 +1,4 @@ export const PLUGIN_NAME = "opencode-translate" -export const SPEC_VERSION = 2 export const LLM_LANGUAGE = "English" export const DEFAULT_TRIGGER = ["$en"] -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" -export const NONCE_PATTERN = /^[0-9a-f]{32}$/ export const FAILURE_NOTICE = "_Translation unavailable for this segment._" -export const AUTH_ENV_FALLBACK = "the provider's API key env var" -export const USER_AGENT = `${PLUGIN_NAME}/0.0.0` diff --git a/src/constants/types.ts b/src/constants/types.ts index d7c4f37..0662b16 100644 --- a/src/constants/types.ts +++ b/src/constants/types.ts @@ -1,9 +1,3 @@ -import type { LLM_LANGUAGE } from "./plugin" - -export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise - -type ProviderSource = "env" | "config" | "custom" | "api" - export interface ResolvedTranslateOptions { model: string variant?: string @@ -11,149 +5,3 @@ export interface ResolvedTranslateOptions { lang: string verbose: boolean } - -export interface TranslateState { - translate_enabled: true - translate_user_lang: string - translate_llm_lang: typeof LLM_LANGUAGE - translate_nonce: string -} - -export interface StoredTextMetadata extends Record { - translate_enabled?: boolean - translate_user_lang?: string - translate_llm_lang?: string - translate_nonce?: string - translate_role?: string - translate_spec_version?: number - translate_source_hash?: string - translate_en?: string - translate_part_index?: number - compaction_continue?: boolean -} - -interface SessionLike { - id: string - parentID?: string | null -} - -interface MessageLike { - id: string - sessionID: string - role: string -} - -export interface TextPartLike { - id: string - sessionID: string - messageID: string - type: string - text?: string - synthetic?: boolean - ignored?: boolean - metadata?: Record -} - -export interface MessageWithPartsLike { - info: MessageLike - parts: TextPartLike[] -} - -export interface ProviderModelInfo { - id?: string - api?: { - id?: string - url?: string - npm?: string - } - headers?: Record - options?: Record - variants?: Record> - capabilities?: { - temperature?: boolean - } -} - -export interface ProviderInfo { - id: string - source: ProviderSource - env: string[] - key?: string - options?: Record - models?: Record -} - -interface ProviderListResponseLike { - all: ProviderInfo[] -} - -interface ApiAuthInfo { - type: "api" - key: string - metadata?: Record -} - -export interface OAuthInfo { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -interface WellKnownInfo { - type: "wellknown" - key: string - token: string -} - -export type AuthInfo = ApiAuthInfo | OAuthInfo | WellKnownInfo - -export interface SDKResponseLike { - data?: T -} - -export interface PluginClientLike { - session: { - get( - input: ( - | { sessionID: string; directory?: string; workspace?: string } - | { path: { id: string }; query?: { directory?: string; workspace?: string } } - ) & { throwOnError?: boolean }, - options?: { throwOnError?: boolean }, - ): Promise> - messages( - input: ( - | { sessionID: string; directory?: string; workspace?: string } - | { path: { id: string }; query?: { directory?: string; workspace?: string; limit?: number; before?: string } } - ) & { throwOnError?: boolean }, - options?: { throwOnError?: boolean }, - ): Promise> - message( - input: ( - | { sessionID: string; messageID: string; directory?: string; workspace?: string } - | { path: { id: string; messageID: string }; query?: { directory?: string; workspace?: string } } - ) & { throwOnError?: boolean }, - options?: { throwOnError?: boolean }, - ): Promise> - } - provider: { - list(options?: { - throwOnError?: boolean - }): Promise> - } - auth: { - set(input: { path: { id: string }; body: AuthInfo }): Promise - } - app: { - log(input: { - body: { - service: string - level: string - message: string - extra?: Record - } - }): Promise - } -} diff --git a/src/index.ts b/src/index.ts index 4aa34a1..777a4de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ -import type { Plugin, PluginInput, PluginOptions } from "@opencode-ai/plugin" -import { createHooks } from "./activation" +import { Plugin } from "@opencode/plugin" +import { setup } from "./activation" -export const OpencodeTranslate: Plugin = async (ctx: PluginInput, options?: PluginOptions) => - createHooks(ctx, options ?? {}) +export const OpencodeTranslate = Plugin.define({ id: "opencode-translate", setup }) export default OpencodeTranslate diff --git a/src/question-tool.ts b/src/question-tool.ts index 5c33132..a66df5a 100644 --- a/src/question-tool.ts +++ b/src/question-tool.ts @@ -6,8 +6,8 @@ // 2. `tool.execute.before` hook translates all question text, headers, and // option labels + descriptions into the configured `lang` so the question // prompt renders in the user's language. -// 3. OpenCode publishes `question.asked`; the TUI shows the translated -// dialog and the user picks an option (or types a custom answer). +// 3. OpenCode displays the translated form in the terminal/web UI and the +// user picks an option (or types a custom answer). // 4. `tool.execute.after` hook reverses the substitution using the // snapshot we captured in step 2. Selected options are restored by // label mapping; non-empty custom answers are translated like normal @@ -68,10 +68,6 @@ export function snapshotQuestions(args: QuestionArgs): TextRecord[] { return args.questions.map(cloneQuestion) } -export function restoreQuestionArgs(args: QuestionArgs, original: readonly TextRecord[]): void { - args.questions.splice(0, args.questions.length, ...original.map(cloneQuestion)) -} - export function isQuestionArgs(value: unknown): value is QuestionArgs { if (!value || typeof value !== "object") return false const questions = (value as Record).questions @@ -199,7 +195,7 @@ function formatRestoredOutput(original: readonly TextRecord[], answers: readonly // Reconstruct the exact output string the question tool would have produced // if it had been called with the original English args. Mirrors the format -// in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x). +// in OpenCode v2's public question-tool result. export async function buildRestoredOutput( original: readonly TextRecord[], translated: readonly TextRecord[], diff --git a/src/questions.ts b/src/questions.ts new file mode 100644 index 0000000..62e8383 --- /dev/null +++ b/src/questions.ts @@ -0,0 +1,63 @@ +import type { Plugin } from "@opencode/plugin" +import { LLM_LANGUAGE, PLUGIN_NAME } from "./constants" +import { + isQuestionArgs, + type QuestionSnapshot, + restoreQuestionOutput, + snapshotQuestions, + translateQuestionArgs, +} from "./question-tool" +import type { createState } from "./state" +import type { Translator } from "./translator" + +export async function registerQuestionHooks( + ctx: Plugin.Context, + state: ReturnType, + translator: Translator, +) { + const snapshots = new Map() + await ctx.tool.hook("execute.before", async (event) => { + if (event.tool !== "question" || !isQuestionArgs(event.input)) return + const lang = await state.language(event.sessionID) + if (!lang) return + const original = snapshotQuestions(event.input) + try { + // The after hook's input is readonly in v2. Persist the original for the + // context hook instead of mutating completed tool calls in the transcript. + await ctx.storage.set(`questions/${event.sessionID}/${event.id}`, JSON.stringify({ questions: original })) + await translateQuestionArgs(event.input, (texts) => translator.texts(texts, LLM_LANGUAGE, lang)) + snapshots.set(`${event.sessionID}/${event.id}`, { + original, + translated: snapshotQuestions(event.input), + userLanguage: lang, + }) + // Cancellation defects in older hosts bypass execute.after. + if (snapshots.size > 1000) snapshots.delete(snapshots.keys().next().value!) + } catch (error) { + console.error(`[${PLUGIN_NAME}] question translation failed`, error) + } + }) + await ctx.tool.hook("execute.after", async (event) => { + const key = `${event.sessionID}/${event.id}` + const snapshot = snapshots.get(key) + if (!snapshot) return + snapshots.delete(key) + if (event.status !== "completed") return + // v2 carries both structured output and model-visible content. + const result = { + output: typeof event.result.content === "string" ? event.result.content : "", + metadata: { ...event.result.metadata }, + } + await restoreQuestionOutput(result, snapshot, { + translateCustomAnswers: (texts) => translator.texts(texts, snapshot.userLanguage, LLM_LANGUAGE), + onTranslationError: async (error) => console.error(`[${PLUGIN_NAME}] answer translation failed`, error), + }) + event.result = { + ...event.result, + content: result.output, + output: { answers: result.metadata.answers }, + metadata: result.metadata, + } + }) + return () => snapshots.clear() +} diff --git a/src/response.ts b/src/response.ts new file mode 100644 index 0000000..13e30fc --- /dev/null +++ b/src/response.ts @@ -0,0 +1,111 @@ +import { composeTranslatedAssistantText, composeTranslationFailureText } from "./formatting" +import { getDisplayLanguageLabel } from "./labels" +import { createAdapter, type Protocol } from "./response/protocols" + +export interface ResponseTranslation { + lang: string + signal: AbortSignal + translate(text: string, signal: AbortSignal): Promise + remember(display: string, english: string): Promise + warn(message: string): void +} + +function protocolFor(request: Request): Protocol | undefined { + const path = new URL(request.url).pathname + if (/\/responses\/?$/.test(path)) return "responses" + if (/\/chat\/completions\/?$/.test(path)) return "chat" + if (/\/messages\/?$/.test(path)) return "anthropic" + if (/:streamGenerateContent$/.test(path)) return "gemini" +} + +// Modify only recognized SSE text protocols. Binary Bedrock, images, and custom +// protocols stay byte-for-byte untouched rather than guessing their wire format. +export function translateResponse(request: Request, response: Response, options: ResponseTranslation): Response { + const protocol = protocolFor(request) + if (!protocol || !response.headers.get("content-type")?.includes("text/event-stream") || !response.body) { + options.warn("Inline translation unavailable for this response protocol; preserving English output") + return response + } + const cancelled = new AbortController() + const signal = AbortSignal.any([options.signal, request.signal, cancelled.signal]) + const reader = response.body.getReader() + const abort = () => { + void reader.cancel(signal.reason).catch(() => {}) + } + signal.addEventListener("abort", abort, { once: true }) + if (signal.aborted) abort() + const adapter = createAdapter(protocol, async (english) => { + if (!english.trim()) return english + signal.throwIfAborted() + let display: string + try { + const translated = await options.translate(english, signal) + if (!translated.trim()) throw new Error("Translator returned empty text") + display = composeTranslatedAssistantText(english, getDisplayLanguageLabel(options.lang), translated) + } catch (error) { + signal.throwIfAborted() + options.warn(`Outbound translation failed: ${String(error)}`) + display = composeTranslationFailureText(english) + } + // Commit provenance before emitting any trailer. If storage fails, do not + // introduce text that a future model request could not remove. + try { + await options.remember(display, english) + } catch (error) { + options.warn(`Cannot save translation history: ${String(error)}`) + return english + } + return display + }) + async function* frames() { + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + signal.throwIfAborted() + const { value, done } = await reader.read() + signal.throwIfAborted() + buffer += done ? decoder.decode() : decoder.decode(value, { stream: true }) + let boundary = /\r\n\r\n|\n\n|\r\r/.exec(buffer) + while (boundary) { + const frame = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + for (const output of await adapter(frame)) yield `${output}\n\n` + boundary = /\r\n\r\n|\n\n|\r\r/.exec(buffer) + } + if (done) { + // An unterminated frame is an incomplete provider stream, not a text + // completion. Preserve it without manufacturing a successful finish. + if (buffer) yield buffer + break + } + } + } finally { + signal.removeEventListener("abort", abort) + await reader.cancel().catch(() => {}) + reader.releaseLock() + } + } + const iterator = frames() + const encoder = new TextEncoder() + const body = new ReadableStream({ + async pull(controller) { + try { + const next = await iterator.next() + if (next.done) controller.close() + else controller.enqueue(encoder.encode(next.value)) + } catch (error) { + controller.error(error) + } + }, + async cancel(reason) { + cancelled.abort(reason) + await iterator.return(undefined) + }, + }) + const headers = new Headers(response.headers) + headers.delete("content-length") + headers.delete("content-encoding") + headers.delete("etag") + return new Response(body, { status: response.status, statusText: response.statusText, headers }) +} diff --git a/src/response/protocols.ts b/src/response/protocols.ts new file mode 100644 index 0000000..a495c3b --- /dev/null +++ b/src/response/protocols.ts @@ -0,0 +1,203 @@ +export type Protocol = "responses" | "chat" | "anthropic" | "gemini" + +// This is the text-bearing subset of the four wire formats. JSON is untrusted: +// text edits are guarded, and all unrecognized fields/events are retained. +interface Wire { + type?: string + id?: string + item_id?: string + index?: number + output_index?: number + content_index?: number + text?: string + delta?: string | Wire + content_block?: Wire + part?: Wire + item?: Wire & { content?: Wire[] } + response?: { output?: (Wire & { content?: Wire[] })[] } + choices?: { index?: number; delta?: { content?: string }; finish_reason?: string | null }[] + candidates?: { + index?: number + content?: { role?: string; parts?: { text?: string; thought?: boolean; thoughtSignature?: string }[] } + finishReason?: string + }[] +} + +interface Segment { + english: string + display?: string + emitted: string +} + +export function createAdapter(protocol: Protocol, translate: (text: string) => Promise) { + const segments = new Map() + const itemIDs = new Map() + function segment(key: string) { + let value = segments.get(key) + if (!value) { + value = { english: "", emitted: "" } + segments.set(key, value) + } + return value + } + async function finish(key: string, full?: string) { + const value = segment(key) + if (value.display !== undefined) return value + if (typeof full === "string") value.english = full + if (!value.english.startsWith(value.emitted)) { + value.display = value.english + return value + } + value.display = value.english ? await translate(value.english) : "" + return value + } + function delta(key: string, text: string) { + const value = segment(key) + value.english += text + value.emitted += text + } + function suffix(value: Segment) { + // A provider's authoritative final may disagree with streamed deltas. Never + // attempt a destructive edit to text already displayed by OpenCode. + if (!value.display?.startsWith(value.emitted)) return "" + const result = value.display.slice(value.emitted.length) + value.emitted = value.display + return result + } + return async (frame: string): Promise => { + const lines = frame.split(/\r\n|\r|\n/) + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n") + if (!data || data === "[DONE]") return [frame] + let event: Wire + try { + const parsed: unknown = JSON.parse(data) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [frame] + event = parsed as Wire + } catch { + return [frame] + } + const extra: string[] = [] + let changed = false + function emit(value: Wire) { + extra.push(`${value.type ? `event: ${value.type}\n` : ""}data: ${JSON.stringify(value)}`) + } + + if (protocol === "anthropic") { + const key = String(event.index ?? 0) + if ( + event.type === "content_block_start" && + event.content_block?.type === "text" && + typeof event.content_block.text === "string" + ) + delta(key, event.content_block.text) + if ( + event.type === "content_block_delta" && + typeof event.delta === "object" && + event.delta?.type === "text_delta" && + typeof event.delta.text === "string" + ) + delta(key, event.delta.text) + if (event.type === "content_block_stop" && segments.has(key)) { + const text = suffix(await finish(key)) + if (text) emit({ type: "content_block_delta", index: event.index, delta: { type: "text_delta", text } }) + } + } + + if (protocol === "chat" && Array.isArray(event.choices)) { + for (const choice of event.choices) { + const key = String(choice.index ?? 0) + if (typeof choice.delta?.content === "string") delta(key, choice.delta.content) + if (choice.finish_reason && segments.has(key)) { + // Keep the final chunk's own delta before the translation, but don't + // close the choice until the trailer has been emitted. + const text = suffix(await finish(key)) + if (text) { + choice.delta = { ...choice.delta, content: `${choice.delta?.content ?? ""}${text}` } + changed = true + } + } + } + } + + if (protocol === "gemini" && Array.isArray(event.candidates)) { + for (const candidate of event.candidates) { + const key = String(candidate.index ?? 0) + for (const part of candidate.content?.parts ?? []) { + if (typeof part.text === "string" && part.thought !== true) delta(key, part.text) + } + if (candidate.finishReason && segments.has(key)) { + const text = suffix(await finish(key)) + if (text) { + // Preserve thought signatures and tool/media parts on their original + // parts. Only the added plain-text part is plugin-generated. + candidate.content = { ...candidate.content, parts: [...(candidate.content?.parts ?? []), { text }] } + changed = true + } + } + } + } + + if (protocol === "responses") { + if (event.item?.id && event.output_index !== undefined) itemIDs.set(event.output_index, event.item.id) + const resolvedID = event.item_id ?? itemIDs.get(event.output_index ?? 0) + const key = `${resolvedID ?? event.output_index ?? 0}/${event.content_index ?? 0}` + if (event.type === "response.output_text.delta" && typeof event.delta === "string") delta(key, event.delta) + async function complete(itemID: string | number, index: number, text: string, outputIndex?: number) { + const value = await finish(`${itemID}/${index}`, text) + const addition = suffix(value) + if (addition) + emit({ + type: "response.output_text.delta", + item_id: String(itemID), + output_index: outputIndex, + content_index: index, + delta: addition, + }) + return value.display ?? text + } + if (event.type === "response.output_text.done" && typeof event.text === "string") { + event.text = await complete( + resolvedID ?? event.output_index ?? 0, + event.content_index ?? 0, + event.text, + event.output_index, + ) + changed = true + } + if ( + event.type === "response.content_part.done" && + event.part?.type === "output_text" && + typeof event.part.text === "string" + ) { + event.part.text = await complete( + resolvedID ?? event.output_index ?? 0, + event.content_index ?? 0, + event.part.text, + event.output_index, + ) + changed = true + } + async function item(value: Wire & { content?: Wire[] }, outputIndex?: number) { + if (value.type !== "message" || !Array.isArray(value.content)) return + for (const [index, part] of value.content.entries()) { + if (part.type !== "output_text" || typeof part.text !== "string") continue + part.text = await complete(value.id ?? outputIndex ?? 0, index, part.text, outputIndex) + changed = true + } + } + if (event.type === "response.output_item.done" && event.item) await item(event.item, event.output_index) + if ( + (event.type === "response.completed" || event.type === "response.incomplete") && + Array.isArray(event.response?.output) + ) { + for (const [index, value] of event.response.output.entries()) await item(value, index) + } + } + if (!changed) return [...extra, frame] + const headers = lines.filter((line) => !line.startsWith("data:")) + return [...extra, [...headers, `data: ${JSON.stringify(event)}`].join("\n")] + } +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..706ecf0 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,97 @@ +import { createHash } from "node:crypto" +import type { Plugin } from "@opencode/plugin" + +export const METADATA_KEY = "opencode-translate" +function hash(text: string) { + return createHash("sha256").update(text).digest("hex") +} + +export function readMetadata(value: unknown): { lang: string; english: string; display: string } | undefined { + if (!value || typeof value !== "object") return + const item = value as Record + if (typeof item.lang === "string" && typeof item.english === "string" && typeof item.display === "string") { + return { lang: item.lang, english: item.english, display: item.display } + } +} + +export function createState(ctx: Plugin.Context) { + async function lineage(sessionID: string) { + const ids = [sessionID] + let current = await ctx.session.get({ sessionID }) + while (current.fork && !ids.includes(current.fork.sessionID)) { + ids.push(current.fork.sessionID) + current = await ctx.session.get({ sessionID: current.fork.sessionID }) + } + return ids + } + return { + async language(sessionID: string): Promise { + const session = await ctx.session.get({ sessionID }) + if (session.parentID) return + const saved = await ctx.storage.get(`sessions/${sessionID}`) + if (typeof saved === "string") return saved + if (session.fork) { + for (const id of (await lineage(sessionID)).slice(1)) { + const inherited = await ctx.storage.get(`sessions/${id}`) + if (typeof inherited === "string") return inherited + } + } + // Admission metadata is also a recovery source if setup/prompt was interrupted. + const messages = await ctx.session.context({ sessionID }) + for (const message of messages) { + const data = readMetadata(message.metadata?.[METADATA_KEY]) + if (message.type === "user" && data) { + await ctx.storage.set(`sessions/${sessionID}`, data.lang) + return data.lang + } + } + }, + async remember(sessionID: string, display: string, english: string) { + await ctx.storage.set(`text/${sessionID}/${hash(display)}`, { display, english }) + }, + async question(sessionID: string, callID: string) { + for (const id of await lineage(sessionID)) { + const value = await ctx.storage.get(`questions/${id}/${callID}`) + if (typeof value === "string") return value + } + }, + async english(sessionID: string, display: string): Promise { + const saved = await ctx.storage.get(`text/${sessionID}/${hash(display)}`) + if ( + saved && + typeof saved === "object" && + "display" in saved && + "english" in saved && + saved.display === display && + typeof saved.english === "string" + ) { + return saved.english + } + // Responses can concatenate several native text parts into one semantic + // message. Restore their recorded spans as well as exact whole messages. + if (!display.includes("\n\n---\n\n")) return display + let text = display + for (const id of await lineage(sessionID)) { + let after: string | undefined + do { + const page = await ctx.storage.scan({ prefix: `text/${id}/`, after, limit: 100 }) + for (const { value } of page.entries) { + if ( + value && + typeof value === "object" && + "display" in value && + "english" in value && + typeof value.display === "string" && + typeof value.english === "string" && + value.display !== value.english + ) { + text = text.replaceAll(value.display, value.english) + } + } + after = page.next + } while (after) + } + return text + }, + } +} diff --git a/src/translator.ts b/src/translator.ts index 9c1f223..67a67f9 100644 --- a/src/translator.ts +++ b/src/translator.ts @@ -1 +1,68 @@ -export * from "./translator/index" +import type { Plugin } from "@opencode/plugin" +import { PLUGIN_NAME, parseTranslatorModel, type ResolvedTranslateOptions } from "./constants" +import { + buildBatchSystemPrompt, + buildBatchUserPrompt, + buildSystemPrompt, + buildUserPrompt, + parseBatchSegments, + unwrapEchoedTextEnvelope, +} from "./prompts" + +export interface Translator { + text(text: string, sourceLanguage: string, targetLanguage: string, signal?: AbortSignal): Promise + texts(texts: readonly string[], sourceLanguage: string, targetLanguage: string): Promise +} + +// Stateless generation uses the host's catalog, SQLite credentials and OAuth refresh. +// It does not enter the session hooks and cannot recursively translate itself. +export function createTranslator( + ctx: Plugin.Context, + options: ResolvedTranslateOptions, + signal: AbortSignal, +): Translator { + const { providerID, modelID } = parseTranslatorModel(options.model) + const model = { providerID, id: modelID, ...(options.variant ? { variant: options.variant } : {}) } + async function generate(prompt: string, requestSignal?: AbortSignal) { + const started = Date.now() + const abort = AbortSignal.any([signal, AbortSignal.timeout(180_000), ...(requestSignal ? [requestSignal] : [])]) + abort.throwIfAborted() + // Some older in-process hosts do not forward RequestOptions.signal. Race it as well + // so interruption always releases the primary response, even on those hosts. + let rejectCancelled: (reason: unknown) => void + const cancelled = new Promise((_, reject) => { + rejectCancelled = reject + }) + const stop = () => rejectCancelled(abort.reason) + abort.addEventListener("abort", stop, { once: true }) + try { + const result = await Promise.race([ctx.generate.text({ model, prompt }, { signal: abort }), cancelled]) + if (options.verbose) + console.info(`[${PLUGIN_NAME}] translated with ${options.model} in ${Date.now() - started}ms`) + return result.text + } finally { + abort.removeEventListener("abort", stop) + } + } + return { + async text(text, sourceLanguage, targetLanguage, requestSignal) { + if (!text || sourceLanguage === targetLanguage) return text + const input = { text, sourceLanguage, targetLanguage } + const translated = unwrapEchoedTextEnvelope( + await generate(`${buildSystemPrompt(input)}\n\n${buildUserPrompt(input)}`, requestSignal), + ) + if (!translated.trim()) throw new Error("Translator returned empty text") + return translated + }, + async texts(texts, sourceLanguage, targetLanguage) { + if (!texts.length || sourceLanguage === targetLanguage) return [...texts] + const input = { texts, sourceLanguage, targetLanguage } + const result = await generate(`${buildBatchSystemPrompt(input)}\n\n${buildBatchUserPrompt(input)}`) + return parseBatchSegments(result, texts.length).map((text, index) => { + const translated = unwrapEchoedTextEnvelope(text) + if (texts[index].trim() && !translated.trim()) throw new Error("Translator returned an empty segment") + return translated + }) + }, + } +} diff --git a/src/translator/index.ts b/src/translator/index.ts deleted file mode 100644 index c1ad8a9..0000000 --- a/src/translator/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { setTimeout as sleep } from "node:timers/promises" -import { generateText, type LanguageModel } from "ai" -import { createCredentialResolver } from "../auth" -import { - buildAuthUnavailableError, - PLUGIN_NAME, - type PluginClientLike, - type ProviderInfo, - parseTranslatorModel, - type ResolvedTranslateOptions, -} from "../constants" -import { - buildBatchSystemPrompt, - buildBatchUserPrompt, - buildSystemPrompt, - buildUserPrompt, - parseBatchSegments, - unwrapEchoedTextEnvelope, -} from "../prompts" -import { __resetSyntheticPartIDForTest } from "./part-id" -import { - __resetProviderFactoryCacheForTest, - buildVariantProviderOptions, - instantiateModel, - instantiateProvider, - loadFactory, - resolveModelInfo, - supportsTemperature, -} from "./provider" -import { withRetry } from "./retry" -import type { TranslateTextInput, TranslateTextsInput, TranslatorDependencies } from "./types" - -const DEFAULT_TRANSLATE_TIMEOUT_MS = 180_000 - -function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs) - promise.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error) => { - clearTimeout(timer) - reject(error) - }, - ) - }) -} - -function isAuthMessage(error: unknown): boolean { - if (!(error instanceof Error)) return false - return error.message.includes(":AUTH_UNAVAILABLE]") || error.message.includes(":OAUTH_REFRESH_FAILED]") -} - -function modelProviderHint(providerID: string, provider?: ProviderInfo): Error { - return buildAuthUnavailableError(providerID, provider?.env[0] || "the provider's API key env var") -} - -export function __resetTranslatorCachesForTest() { - __resetProviderFactoryCacheForTest() - __resetSyntheticPartIDForTest() -} - -export function createTranslator( - client: PluginClientLike, - options: ResolvedTranslateOptions, - deps: TranslatorDependencies = {}, -) { - const sleepImpl = deps.sleep ?? ((ms: number) => sleep(ms)) - const now = deps.now ?? (() => Date.now()) - const generateTextImpl = deps.generateTextImpl ?? generateText - const credentialResolver = deps.credentialResolver ?? createCredentialResolver(client) - const timeoutMs = deps.timeoutMs ?? DEFAULT_TRANSLATE_TIMEOUT_MS - - async function generateFromPrompts(system: string, prompt: string): Promise { - const { providerID, modelID } = parseTranslatorModel(options.model) - const credentials = await credentialResolver.resolve(options.model) - const modelInfo = resolveModelInfo(credentials.provider, modelID) - const variantProviderOptions = buildVariantProviderOptions(providerID, modelID, modelInfo, options.variant) - const factory = await loadFactory(providerID, modelInfo) - const provider = instantiateProvider(factory, providerID, credentials, modelInfo) - const providerOptions = { ...(credentials.provider?.options ?? {}), ...(modelInfo.options ?? {}) } - const model = instantiateModel(provider, modelID, providerID, modelInfo, providerOptions) as LanguageModel - - return withRetry(async () => { - try { - const result = await withTimeout( - generateTextImpl({ - model, - system, - ...(supportsTemperature(providerID, modelID, modelInfo) ? { temperature: 0 } : {}), - ...(variantProviderOptions ? { providerOptions: variantProviderOptions } : {}), - prompt, - }), - timeoutMs, - "Translator generateText", - ) - return result.text - } catch (error) { - if (isAuthMessage(error)) throw error - if (credentials.mode === "default" && credentialResolver.isMissingCredentialError(error)) { - throw modelProviderHint(providerID, credentials.provider) - } - throw error - } - }, sleepImpl) - } - - async function translateText(input: TranslateTextInput): Promise { - if (!input.text) return input.text - if (input.sourceLanguage === input.targetLanguage) return input.text - - const startedAt = now() - const rawTranslated = await generateFromPrompts(buildSystemPrompt(input), buildUserPrompt(input)) - const translated = unwrapEchoedTextEnvelope(rawTranslated) - - if (options.verbose) { - await client.app.log({ - body: { - service: PLUGIN_NAME, - level: "info", - message: "translated", - extra: { - direction: input.direction, - chars_in: input.text.length, - chars_out: translated.length, - ms: now() - startedAt, - cached: false, - model: options.model, - }, - }, - }) - } - - return translated - } - - async function translateTexts(input: TranslateTextsInput): Promise { - if (input.texts.length === 0) return [] - if (input.sourceLanguage === input.targetLanguage) return [...input.texts] - - const startedAt = now() - const rawTranslated = await generateFromPrompts(buildBatchSystemPrompt(input), buildBatchUserPrompt(input)) - const translated = parseBatchSegments(rawTranslated, input.texts.length).map(unwrapEchoedTextEnvelope) - - if (options.verbose) { - await client.app.log({ - body: { - service: PLUGIN_NAME, - level: "info", - message: "translated", - extra: { - direction: input.direction, - chars_in: input.texts.reduce((total, text) => total + text.length, 0), - chars_out: translated.reduce((total, text) => total + text.length, 0), - segments: input.texts.length, - ms: now() - startedAt, - cached: false, - model: options.model, - }, - }, - }) - } - - return translated - } - - return { translateText, translateTexts } -} - -export { __resetSyntheticPartIDForTest, createSyntheticPartID, hashText } from "./part-id" diff --git a/src/translator/part-id.ts b/src/translator/part-id.ts deleted file mode 100644 index 830d935..0000000 --- a/src/translator/part-id.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createHash, randomBytes } from "node:crypto" - -const PART_ID_LENGTH = 26 -const PART_ID_PREFIX = "prt" -const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - -let partLastTimestamp = 0 -let partCounter = 0 - -export function __resetSyntheticPartIDForTest() { - partLastTimestamp = 0 - partCounter = 0 -} - -function randomBase62(length: number): string { - const bytes = randomBytes(length) - let result = "" - for (let index = 0; index < length; index += 1) { - result += BASE62_CHARS[bytes[index] % BASE62_CHARS.length] - } - return result -} - -export function hashText(text: string): string { - return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16) -} - -export function createSyntheticPartID(): string { - const currentTimestamp = Date.now() - if (currentTimestamp !== partLastTimestamp) { - partLastTimestamp = currentTimestamp - partCounter = 0 - } - partCounter += 1 - - const encoded = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(partCounter) - const timeBytes = Buffer.alloc(6) - for (let index = 0; index < 6; index += 1) { - timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff)) - } - - return `${PART_ID_PREFIX}_${timeBytes.toString("hex")}${randomBase62(PART_ID_LENGTH - 12)}` -} diff --git a/src/translator/provider.ts b/src/translator/provider.ts deleted file mode 100644 index 679d82c..0000000 --- a/src/translator/provider.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { type AuthInfo, type FetchLike, PLUGIN_NAME, type ProviderInfo, type ProviderModelInfo } from "../constants" - -const providerFactoryCache = new Map() - -const PROVIDER_PACKAGE_FALLBACK: Record = { - anthropic: "@ai-sdk/anthropic", - openai: "@ai-sdk/openai", - google: "@ai-sdk/google", - "google-vertex": "@ai-sdk/google-vertex", - "amazon-bedrock": "@ai-sdk/amazon-bedrock", - "github-copilot": "@ai-sdk/openai-compatible", -} - -const CREATE_EXPORT_FALLBACK: Record = { - "@ai-sdk/amazon-bedrock": ["createAmazonBedrock", "bedrock"], - "@ai-sdk/anthropic": ["createAnthropic", "anthropic"], - "@ai-sdk/azure": ["createAzure", "azure"], - "@ai-sdk/gateway": ["createGateway", "gateway"], - "@ai-sdk/google": ["createGoogleGenerativeAI", "google"], - "@ai-sdk/google-vertex": ["createVertex", "vertex"], - "@ai-sdk/openai": ["createOpenAI", "openai"], - "@ai-sdk/openai-compatible": ["createOpenAICompatible"], - "@openrouter/ai-sdk-provider": ["createOpenRouter", "openrouter"], -} - -const PROVIDER_OPTIONS_KEY: Record = { - "@ai-sdk/amazon-bedrock": "bedrock", - "@ai-sdk/amazon-bedrock/mantle": "openai", - "@ai-sdk/anthropic": "anthropic", - "@ai-sdk/azure": "openai", - "@ai-sdk/gateway": "gateway", - "@ai-sdk/github-copilot": "openai", - "@ai-sdk/google": "google", - "@ai-sdk/google-vertex": "vertex", - "@ai-sdk/google-vertex/anthropic": "anthropic", - "@ai-sdk/openai": "openai", - "@openrouter/ai-sdk-provider": "openrouter", - "ai-gateway-provider": "openaiCompatible", -} - -type JsonValue = null | string | number | boolean | JsonObject | JsonArray -type JsonObject = { [key: string]: JsonValue | undefined } -type JsonArray = JsonValue[] -type VariantProviderOptions = Record - -interface ProviderCredentials { - provider?: ProviderInfo - authInfo?: AuthInfo - apiKey?: string - fetch?: FetchLike -} - -export function __resetProviderFactoryCacheForTest() { - providerFactoryCache.clear() -} - -function providerPackage(providerID: string, model?: ProviderModelInfo): string { - const packageName = model?.api?.npm || PROVIDER_PACKAGE_FALLBACK[providerID] - if (!packageName) throw new Error(`Unsupported translator provider "${providerID}"`) - return packageName -} - -function pickFactory(mod: Record, packageName: string): unknown { - for (const key of CREATE_EXPORT_FALLBACK[packageName] ?? []) { - if (typeof mod[key] === "function") return mod[key] - } - const createKey = Object.keys(mod).find((key) => key.startsWith("create") && typeof mod[key] === "function") - return createKey ? mod[createKey] : undefined -} - -export async function loadFactory(providerID: string, model?: ProviderModelInfo): Promise { - const packageName = providerPackage(providerID, model) - const cached = providerFactoryCache.get(packageName) - if (cached) return cached - - let mod: Record - try { - mod = (await import(packageName)) as Record - } catch (error) { - throw new Error(`Unable to load provider package "${packageName}" for "${providerID}": ${String(error)}`) - } - const factory = pickFactory(mod, packageName) - - if (typeof factory !== "function") { - throw new Error(`Unable to load provider factory from "${packageName}" for "${providerID}"`) - } - - providerFactoryCache.set(packageName, factory) - return factory -} - -export function resolveModelInfo(provider: ProviderInfo | undefined, modelID: string): ProviderModelInfo { - return provider?.models?.[modelID] ?? { id: modelID, api: { id: modelID } } -} - -function sdkProviderOptionsKey(providerID: string, model?: ProviderModelInfo): string { - const packageName = model?.api?.npm - if (packageName && PROVIDER_OPTIONS_KEY[packageName]) return PROVIDER_OPTIONS_KEY[packageName] - if (packageName === "@ai-sdk/openai-compatible" || packageName === "@ai-sdk/openai") return providerID.split(".")[0] - return providerID -} - -function invalidVariantError(providerID: string, modelID: string, model: ProviderModelInfo, variant: string) { - const variants = Object.keys(model.variants ?? {}).sort() - const modelName = `${providerID}/${modelID}` - if (variants.length === 0) { - return new Error( - `[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". This model has no configurable variants.`, - ) - } - return new Error( - `[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". Available variants: ${variants.join(", ")}.`, - ) -} - -export function buildVariantProviderOptions( - providerID: string, - modelID: string, - model: ProviderModelInfo, - variant?: string, -): VariantProviderOptions | undefined { - if (!variant) return undefined - const selected = model.variants?.[variant] - if (!selected) throw invalidVariantError(providerID, modelID, model, variant) - const providerOptions = selected as JsonObject - if (model.api?.npm === "@ai-sdk/azure") return { openai: providerOptions, azure: providerOptions } - return { [sdkProviderOptionsKey(providerID, model)]: providerOptions } -} - -function headerRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return {} - return Object.fromEntries( - Object.entries(value as Record).filter((entry): entry is [string, string] => { - return typeof entry[1] === "string" - }), - ) -} - -function substitutionVars(options: Record, authInfo?: AuthInfo): Record { - const metadata = authInfo?.type === "api" ? authInfo.metadata : undefined - const location = - stringOption(options.location) ?? process.env.GOOGLE_VERTEX_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION - const vertexEndpoint = - location === "global" ? "aiplatform.googleapis.com" : location ? `${location}-aiplatform.googleapis.com` : undefined - return { - ...process.env, - AZURE_RESOURCE_NAME: - stringOption(options.resourceName) ?? metadata?.resourceName ?? process.env.AZURE_RESOURCE_NAME, - GOOGLE_VERTEX_PROJECT: - stringOption(options.project) ?? - process.env.GOOGLE_VERTEX_PROJECT ?? - process.env.GOOGLE_CLOUD_PROJECT ?? - process.env.GCP_PROJECT ?? - process.env.GCLOUD_PROJECT, - GOOGLE_VERTEX_LOCATION: location, - GOOGLE_VERTEX_ENDPOINT: vertexEndpoint ?? process.env.GOOGLE_VERTEX_ENDPOINT, - CLOUDFLARE_ACCOUNT_ID: metadata?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID, - CLOUDFLARE_GATEWAY_ID: metadata?.gatewayId ?? process.env.CLOUDFLARE_GATEWAY_ID, - } -} - -function stringOption(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined -} - -function resolveBaseURL(baseURL: unknown, apiURL: unknown, options: Record, authInfo?: AuthInfo) { - let url = stringOption(baseURL) ?? stringOption(apiURL) - if (!url) return undefined - const vars = substitutionVars(options, authInfo) - url = url.replace(/\$\{([^}]+)\}/g, (match, key) => vars[String(key)] ?? match) - return url -} - -function wrapSSE(response: Response, ms: number, controller: AbortController) { - if (typeof ms !== "number" || ms <= 0) return response - if (!response.body) return response - if (!response.headers.get("content-type")?.includes("text/event-stream")) return response - - const reader = response.body.getReader() - const body = new ReadableStream({ - async pull(ctrl) { - const part = await new Promise>>((resolve, reject) => { - const id = setTimeout(() => { - const error = new Error("SSE read timed out") - controller.abort(error) - void reader.cancel(error) - reject(error) - }, ms) - - reader.read().then( - (value) => { - clearTimeout(id) - resolve(value) - }, - (error) => { - clearTimeout(id) - reject(error) - }, - ) - }) - - if (part.done) { - ctrl.close() - return - } - - ctrl.enqueue(part.value) - }, - async cancel(reason) { - controller.abort(reason) - await reader.cancel(reason) - }, - }) - - return new Response(body, { - headers: new Headers(response.headers), - status: response.status, - statusText: response.statusText, - }) -} - -function anySignal(signals: AbortSignal[]): AbortSignal | undefined { - if (signals.length === 0) return undefined - if (signals.length === 1) return signals[0] - const signalAny = (AbortSignal as typeof AbortSignal & { any?: (signals: AbortSignal[]) => AbortSignal }).any - return signalAny ? signalAny(signals) : signals[0] -} - -function stripOpenAIItemIDs(packageName: string, init: RequestInit) { - if (packageName !== "@ai-sdk/openai" && packageName !== "@ai-sdk/azure") return - if (!init.body || init.method !== "POST" || typeof init.body !== "string") return - try { - const body = JSON.parse(init.body) as Record - if (body.store === true || !Array.isArray(body.input)) return - for (const item of body.input) { - if (item && typeof item === "object" && !Array.isArray(item)) delete (item as Record).id - } - init.body = JSON.stringify(body) - } catch {} -} - -function withOpenCodeFetch(config: Record, packageName: string) { - const configuredFetch = typeof config.fetch === "function" ? (config.fetch as FetchLike) : undefined - const chunkTimeout = typeof config.chunkTimeout === "number" ? config.chunkTimeout : undefined - delete config.chunkTimeout - - config.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const requestInit = { ...(init ?? {}) } - const signals: AbortSignal[] = [] - const chunkController = chunkTimeout && chunkTimeout > 0 ? new AbortController() : undefined - if (requestInit.signal) signals.push(requestInit.signal) - if (chunkController) signals.push(chunkController.signal) - if (typeof config.timeout === "number" && config.timeout > 0) signals.push(AbortSignal.timeout(config.timeout)) - const signal = anySignal(signals) - if (signal) requestInit.signal = signal - stripOpenAIItemIDs(packageName, requestInit) - - const response = await (configuredFetch ?? fetch)(input, { ...requestInit, timeout: false } as RequestInit) - return chunkController && chunkTimeout ? wrapSSE(response, chunkTimeout, chunkController) : response - } -} - -function providerConfig( - providerID: string, - credentials: ProviderCredentials, - model?: ProviderModelInfo, -): Record { - const provider = credentials.provider - const packageName = providerPackage(providerID, model) - const config: Record = { ...(provider?.options ?? {}) } - - if (providerID === "google-vertex" && !packageName.includes("@ai-sdk/openai-compatible")) delete config.fetch - if (packageName.includes("@ai-sdk/openai-compatible") && config.includeUsage !== false) config.includeUsage = true - - const baseURL = resolveBaseURL(config.baseURL, model?.api?.url, config, credentials.authInfo) - if (baseURL !== undefined) config.baseURL = baseURL - if (credentials.apiKey !== undefined) config.apiKey = credentials.apiKey - if (credentials.fetch) config.fetch = credentials.fetch - if (model?.headers) config.headers = { ...headerRecord(config.headers), ...model.headers } - if (providerID === "github-copilot" && config.baseURL === undefined) config.baseURL = "https://api.githubcopilot.com" - if ( - providerID === "amazon-bedrock" && - credentials.authInfo?.type === "api" && - !process.env.AWS_BEARER_TOKEN_BEDROCK - ) { - process.env.AWS_BEARER_TOKEN_BEDROCK = credentials.authInfo.key - } - - withOpenCodeFetch(config, packageName) - return { name: providerID, ...config } -} - -export function instantiateProvider( - factory: unknown, - providerID: string, - credentials: ProviderCredentials, - model?: ProviderModelInfo, -): unknown { - if (typeof factory !== "function") throw new Error(`Invalid provider factory for "${providerID}"`) - return (factory as (config: Record) => unknown)(providerConfig(providerID, credentials, model)) -} - -function shouldUseCopilotResponsesApi(modelID: string): boolean { - const match = /^gpt-(\d+)/.exec(modelID) - if (!match) return false - return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") -} - -function selectAzureLanguageModel(record: Record, modelID: string, useChat: boolean): unknown { - if (useChat && typeof record.chat === "function") return (record.chat as (id: string) => unknown)(modelID) - if (typeof record.responses === "function") return (record.responses as (id: string) => unknown)(modelID) - if (typeof record.messages === "function") return (record.messages as (id: string) => unknown)(modelID) - if (typeof record.chat === "function") return (record.chat as (id: string) => unknown)(modelID) - if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(modelID) -} - -function bedrockModelID(modelID: string, region: unknown): string { - const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] - if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID - if (typeof region !== "string") return modelID - - let regionPrefix = region.split("-")[0] - if (regionPrefix === "us") { - const modelRequiresPrefix = [ - "nova-micro", - "nova-lite", - "nova-pro", - "nova-premier", - "nova-2", - "claude", - "deepseek", - ].some((value) => modelID.includes(value)) - if (modelRequiresPrefix && !region.startsWith("us-gov")) return `${regionPrefix}.${modelID}` - } - if (regionPrefix === "eu") { - const regionRequiresPrefix = [ - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-south-1", - "eu-south-2", - ].some((value) => region.includes(value)) - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((value) => - modelID.includes(value), - ) - if (regionRequiresPrefix && modelRequiresPrefix) return `${regionPrefix}.${modelID}` - } - if (regionPrefix === "ap") { - const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region) - const isTokyoRegion = region === "ap-northeast-1" - if ( - isAustraliaRegion && - ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((value) => modelID.includes(value)) - ) { - regionPrefix = "au" - return `${regionPrefix}.${modelID}` - } - const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((value) => - modelID.includes(value), - ) - if (modelRequiresPrefix) return `${isTokyoRegion ? "jp" : "apac"}.${modelID}` - } - return modelID -} - -export function instantiateModel( - provider: unknown, - modelID: string, - providerID?: string, - model?: ProviderModelInfo, - providerOptions?: Record, -): unknown { - const apiID = model?.api?.id || model?.id || modelID - if (typeof provider === "function") return provider(modelID) - if (provider && typeof provider === "object") { - const record = provider as Record - if ((providerID === "openai" || providerID === "xai") && typeof record.responses === "function") { - return (record.responses as (id: string) => unknown)(apiID) - } - if ( - providerID === "github-copilot" && - typeof record.responses === "function" && - typeof record.chat === "function" - ) { - return shouldUseCopilotResponsesApi(apiID) - ? (record.responses as (id: string) => unknown)(apiID) - : (record.chat as (id: string) => unknown)(apiID) - } - if (providerID === "azure" || providerID === "azure-cognitive-services") { - const selected = selectAzureLanguageModel(record, apiID, providerOptions?.useCompletionUrls === true) - if (selected) return selected - } - if (providerID === "amazon-bedrock" && typeof record.languageModel === "function") { - return (record.languageModel as (id: string) => unknown)(bedrockModelID(apiID, providerOptions?.region)) - } - if (typeof record.chatModel === "function") return (record.chatModel as (id: string) => unknown)(modelID) - if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(apiID) - if (typeof record.chat === "function") return (record.chat as (id: string) => unknown)(apiID) - if (typeof record.responses === "function") return (record.responses as (id: string) => unknown)(apiID) - } - throw new Error(`Unable to instantiate model "${modelID}"`) -} - -export function supportsTemperature(providerID: string, modelID: string, model?: ProviderModelInfo): boolean { - if (typeof model?.capabilities?.temperature === "boolean") return model.capabilities.temperature - if (providerID !== "openai") return true - if (modelID.startsWith("o1") || modelID.startsWith("o3") || modelID.startsWith("o4-mini")) return false - return !(modelID.startsWith("gpt-5") && !modelID.startsWith("gpt-5-chat")) -} diff --git a/src/translator/retry.ts b/src/translator/retry.ts deleted file mode 100644 index 324d69f..0000000 --- a/src/translator/retry.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { normalizeReason } from "../constants" - -function getStatus(error: unknown): number | undefined { - if (!error || typeof error !== "object") return undefined - const record = error as Record - if (typeof record.status === "number") return record.status - if (typeof record.statusCode === "number") return record.statusCode - const response = record.response - if (response && typeof response === "object") { - const status = (response as Record).status - if (typeof status === "number") return status - } - return undefined -} - -function getRetryAfterMs(error: unknown): number { - if (!error || typeof error !== "object") return 2000 - const response = (error as Record).response - if (!response || typeof response !== "object") return 2000 - const headers = (response as { headers?: Headers }).headers - if (!(headers instanceof Headers)) return 2000 - const retryAfter = headers.get("retry-after") - if (!retryAfter) return 2000 - const seconds = Number(retryAfter) - if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) - const date = Date.parse(retryAfter) - return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 2000 -} - -function isRetryable(error: unknown): boolean { - const status = getStatus(error) - if (status === 429) return true - if (status !== undefined) return status >= 500 - const message = normalizeReason(error).toLowerCase() - return ( - message.includes("network") || - message.includes("fetch") || - message.includes("timeout") || - message.includes("socket") || - message.includes("econn") - ) -} - -export async function withRetry(task: () => Promise, sleepImpl: (ms: number) => Promise): Promise { - let lastError: unknown - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - return await task() - } catch (error) { - lastError = error - if (!isRetryable(error)) throw error - if (getStatus(error) === 429) { - if (attempt >= 1) throw error - await sleepImpl(getRetryAfterMs(error)) - continue - } - if (attempt >= 2) throw error - await sleepImpl(attempt === 0 ? 500 : 1500) - } - } - throw lastError -} diff --git a/src/translator/types.ts b/src/translator/types.ts deleted file mode 100644 index 4513675..0000000 --- a/src/translator/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { generateText } from "ai" -import type { createCredentialResolver } from "../auth" - -export interface TranslatorDependencies { - generateTextImpl?: typeof generateText - sleep?: (ms: number) => Promise - now?: () => number - credentialResolver?: ReturnType - timeoutMs?: number -} - -export interface TranslateTextInput { - text: string - sourceLanguage: string - targetLanguage: string - direction: "inbound" | "outbound" -} - -export interface TranslateTextsInput { - texts: readonly string[] - sourceLanguage: string - targetLanguage: string - direction: "inbound" | "outbound" -} diff --git a/test/activation.test.ts b/test/activation.test.ts new file mode 100644 index 0000000..406ca5b --- /dev/null +++ b/test/activation.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { setup, stripTrigger } from "../src/activation" +import { composeTranslatedAssistantText } from "../src/formatting" +import { createState, METADATA_KEY } from "../src/state" +import { host, prompt, requestContext } from "./helpers" + +describe("v2 lifecycle and admission", () => { + test("registers v2 hooks and cleanup, including separate auxiliary hooks on released v2", async () => { + const h = host() + const cleanup = await setup(h.ctx) + expect([...h.callbacks.keys()]).toEqual([ + "session.prompt", + "session.context", + "session.title", + "session.compaction", + "session.generate", + "tool.execute.before", + "tool.execute.after", + "session.http.response", + ]) + expect(h.requests).toHaveLength(0) + cleanup?.() + }) + test("pre-release checkout uses its shared context hook", async () => { + const h = host() + Object.assign(h.ctx.app, { version: "1.18.15" }) + await setup(h.ctx) + expect(h.callbacks.has("session.context")).toBe(true) + expect(h.callbacks.has("session.compaction")).toBe(false) + }) + test("activation preserves visible original and only sends English in every context", async () => { + const h = host({ variant: "minimal" }) + h.generate(async () => "Hello") + await setup(h.ctx) + const input = prompt() + await h.emit("session.prompt", input) + expect(input.prompt.text).toBe("안녕하세요\n\n→ EN: Hello") + expect(h.requests[0].model).toEqual({ providerID: "openai", id: "gpt-5.4-mini", variant: "minimal" }) + expect(h.values.get("sessions/ses_1")).toBe("Korean") + for (const name of ["context", "title", "compaction", "generate"]) { + const message = { + role: "user", + content: [ + { type: "text", text: input.prompt.text }, + { type: "media", data: "unchanged" }, + ], + metadata: { ...input.metadata, unrelated: true }, + } + const event = requestContext([message]) + await h.emit(`session.${name}`, event) + expect(event.messages).toEqual([ + { + ...message, + content: [ + { type: "text", text: "Hello" }, + { type: "media", data: "unchanged" }, + ], + metadata: { unrelated: true }, + }, + ]) + expect(message.content[0].text).toBe(input.prompt.text) + } + await h.emit("session.prompt", input) + expect(h.requests).toHaveLength(1) + }) + test("activation survives reload and does not affect inactive or child sessions", async () => { + const h = host() + await setup(h.ctx) + await h.emit("session.prompt", prompt("normal", "inactive")) + h.children.add("child") + await h.emit("session.prompt", prompt("$en 안녕", "child")) + expect(h.requests).toHaveLength(0) + await h.emit("session.prompt", prompt()) + await setup(h.ctx) + await h.emit("session.prompt", prompt("다음 질문")) + expect(h.requests).toHaveLength(2) + expect(h.requests[1].prompt).toContain("다음 질문") + }) + test("recovers state from durable admission metadata", async () => { + const h = host() + h.history.ses_1 = [ + { type: "user", metadata: { [METADATA_KEY]: { lang: "Japanese", english: "Hello", display: "こんにちは" } } }, + ] + expect(await createState(h.ctx).language("ses_1")).toBe("Japanese") + }) + test("translation failure leaves prompt and activation untouched", async () => { + const log = spyOn(console, "error").mockImplementation(() => {}) + const h = host() + h.generate(async () => { + throw new Error("Generation credentials are unavailable") + }) + await setup(h.ctx) + const input = prompt() + try { + await h.emit("session.prompt", input) + expect(log).toHaveBeenCalled() + } finally { + log.mockRestore() + } + expect(input.prompt.text).toBe("$en 안녕하세요") + expect(h.values.get("sessions/ses_1")).toBeUndefined() + }) + test("rewriting removes stale file, agent, and skill mention offsets", async () => { + const h = host() + await setup(h.ctx) + const mention = { start: 0, end: 3, text: "$en" } + const input = { + ...prompt(), + prompt: { + text: "$en 확인", + files: [{ uri: "file:///project/a.ts", mention }], + agents: [{ id: "build", mention }], + skills: [{ id: "review", mention }], + }, + } + await h.emit("session.prompt", input) + expect(input.prompt.files[0].mention).toBeUndefined() + expect(input.prompt.files[0].uri).toBe("file:///project/a.ts") + expect(input.prompt.agents[0].mention).toBeUndefined() + expect(input.prompt.skills[0].mention).toBeUndefined() + }) + test("only recorded outbound spans are removed, including joined native parts", async () => { + const h = host() + await setup(h.ctx) + const state = createState(h.ctx) + const one = composeTranslatedAssistantText("One", "Korean", "하나") + const two = composeTranslatedAssistantText("Two", "Korean", "둘") + await state.remember("ses_1", one, "One") + await state.remember("ses_1", two, "Two") + const event = requestContext([ + { + role: "assistant", + content: [ + { type: "text", text: `${one}${two}` }, + { type: "reasoning", text: "Do not edit" }, + ], + }, + ]) + await h.emit("session.context", event) + expect(event.messages).toMatchObject([{ content: [{ text: "OneTwo" }, { text: "Do not edit" }] }]) + expect(await state.english("other", one)).toBe(one) + expect(await state.english("ses_1", "Not recorded\n\n---\n\n**Korean:**\n\ntext")).toContain("**Korean:**") + }) + test("trigger matching respects boundaries, literal symbols, and earliest occurrence", () => { + expect(stripTrigger("$english", ["$en"])).toBeUndefined() + expect(stripTrigger("before $en after", ["$en"])).toBe("before after") + expect(stripTrigger("$go first $en", ["$en", "$go"])).toBe("first $en") + expect(stripTrigger("[en] yes", ["[en]"])).toBe("yes") + }) + test("forked histories inherit translation records without altering the source transcript", async () => { + const h = host() + const state = createState(h.ctx) + h.values.set("sessions/parent", "Korean") + h.forks.set("fork", "parent") + const display = composeTranslatedAssistantText("Hello", "Korean", "안녕") + await state.remember("parent", display, "Hello") + expect(await state.language("fork")).toBe("Korean") + expect(await state.english("fork", display)).toBe("Hello") + expect(await state.english("unrelated", display)).toBe(display) + h.children.add("fork") + expect(await state.language("fork")).toBeUndefined() + expect(await state.english("fork", display)).toBe("Hello") + }) + test("unsupported inline protocols report a diagnostic while preserving the response", async () => { + const h = host() + h.values.set("sessions/ses_1", "Korean") + await setup(h.ctx) + const original = new Response("binary", { headers: { "content-type": "application/octet-stream" } }) + const event = { + kind: "primary", + sessionID: "ses_1", + request: new Request("https://provider.example/unknown"), + response: original, + } + const log = spyOn(console, "error").mockImplementation(() => {}) + try { + await h.emit("session.http.response", event) + expect(log).toHaveBeenCalledTimes(1) + expect(event.response).toBe(original) + } finally { + log.mockRestore() + } + }) +}) diff --git a/test/activation/cache.test.ts b/test/activation/cache.test.ts deleted file mode 100644 index aee4613..0000000 --- a/test/activation/cache.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import type { TextPartLike } from "../../src/constants" -import { countingClient, storedMessage, textPart } from "./helpers" - -describe("activation session cache", () => { - beforeEach(() => { - __resetActivationCacheForTest() - }) - - test("empty root session lookup before first chat does not block later activation", async () => { - const counted = countingClient([]) - const calls: string[] = [] - const hooks = createHooks( - { client: counted.client, directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(direction) - return `EN:${text}` - }, - }, - }, - ) - - await hooks["experimental.text.complete"]!({ sessionID: "ses_1", messageID: "msg_assistant" } as never, { - text: "pre-activation text", - }) - - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 0 }) - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 0 }) - expect(calls).toEqual(["inbound"]) - expect((output.parts[0] as TextPartLike).text).toContain("안녕\n\n→ EN: EN:안녕") - expect((output.parts[0] as TextPartLike).text).toContain("✓ Translation mode enabled") - expect((output.parts[1] as TextPartLike).metadata?.translate_role).toBe("llm_only_translation") - expect((output.parts[2] as TextPartLike).metadata?.translate_role).toBe("activation_banner") - }) - - test("inactive root cache still allows later activation", async () => { - let translatorCalls = 0 - const counted = countingClient([]) - const hooks = createHooks( - { client: counted.client, directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - translatorCalls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const firstOutput = { message: { id: "msg_first" }, parts: [textPart("p1", "no trigger")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, firstOutput as never) - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 0 }) - expect(translatorCalls).toBe(0) - - await hooks["experimental.chat.messages.transform"]!( - {} as never, - { messages: [storedMessage([textPart("hist", "previous")])] } as never, - ) - const completeOutput = { text: "assistant text" } - await hooks["experimental.text.complete"]!( - { sessionID: "ses_1", messageID: "msg_assistant" } as never, - completeOutput, - ) - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 0 }) - expect(completeOutput.text).toBe("assistant text") - - const laterOutput = { message: { id: "msg_later" }, parts: [textPart("p2", "$en later trigger")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, laterOutput as never) - expect(translatorCalls).toBe(1) - expect((laterOutput.parts[0] as TextPartLike).text).toContain("later trigger\n\n→ EN: EN:later trigger") - expect((laterOutput.parts[2] as TextPartLike).metadata?.translate_role).toBe("activation_banner") - }) - - test("active session cache skips repeated state lookups across hooks", async () => { - const assistantMessage = storedMessage([textPart("assistant", "hello")], "assistant") - const counted = countingClient([], null, assistantMessage) - const calls: string[] = [] - const hooks = createHooks( - { client: counted.client, directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(direction) - return direction === "inbound" ? `EN:${text}` : `KO:${text}` - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 0 }) - - const transformOutput = { messages: [storedMessage((output.parts as TextPartLike[]).map((part) => ({ ...part })))] } - await hooks["experimental.chat.messages.transform"]!({} as never, transformOutput as never) - expect((transformOutput.messages[0].parts[0] as TextPartLike).ignored).toBe(true) - expect((transformOutput.messages[0].parts[1] as TextPartLike).text).toBe("EN:안녕") - - const completeOutput = { text: "hello" } - await hooks["experimental.text.complete"]!( - { sessionID: "ses_1", messageID: "msg_assistant" } as never, - completeOutput, - ) - expect(counted.calls).toEqual({ get: 1, messages: 1, message: 1 }) - expect(calls).toEqual(["inbound", "outbound"]) - expect(completeOutput.text).toContain("KO:hello") - }) -}) diff --git a/test/activation/chat-message.test.ts b/test/activation/chat-message.test.ts deleted file mode 100644 index 8c41044..0000000 --- a/test/activation/chat-message.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import type { TextPartLike } from "../../src/constants" -import { hashText } from "../../src/translator" -import { fakeClient, makeState, storedMessage, textPart } from "./helpers" - -describe("activation chat.message", () => { - beforeEach(() => { - __resetActivationCacheForTest() - }) - - test("later root-session trigger activates from that message", async () => { - let calls = 0 - const hooks = createHooks( - { client: fakeClient([storedMessage([textPart("old", "previous message")])]), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "hello $en world")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(calls).toBe(1) - expect(output.parts).toHaveLength(3) - expect((output.parts[0] as TextPartLike).text).toContain("hello world\n\n→ EN: EN:hello world") - expect((output.parts[0] as TextPartLike).text).toContain("✓ Translation mode enabled") - expect((output.parts[0] as TextPartLike).text).toContain("model: anthropic/claude-haiku-4-5") - expect((output.parts[0] as TextPartLike).text).not.toContain("translator:") - expect((output.parts[0] as TextPartLike).text).not.toContain("$en") - expect((output.parts[0] as TextPartLike).metadata?.translate_en).toBe("EN:hello world") - expect((output.parts[1] as TextPartLike).text).toBe("EN:hello world") - expect((output.parts[1] as TextPartLike).synthetic).toBe(true) - expect((output.parts[1] as TextPartLike).metadata?.translate_role).toBe("llm_only_translation") - expect((output.parts[2] as TextPartLike).metadata?.translate_role).toBe("activation_banner") - }) - - test("child sessions are a no-op", async () => { - let calls = 0 - const hooks = createHooks( - { client: fakeClient([], "ses_parent"), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(calls).toBe(0) - expect(output.parts).toHaveLength(1) - expect((output.parts[0] as TextPartLike).text).toBe("$en 안녕") - }) - - test("forked translated session inherits translation mode without a new trigger", async () => { - let calls = 0 - const state = makeState() - const hooks = createHooks( - { - client: fakeClient([ - storedMessage([ - textPart("hist", "이전 메시지", { - metadata: { ...state, translate_en: "previous message", translate_source_hash: hashText("이전 메시지") }, - }), - ]), - ]), - directory: "/workspace", - } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "새 메시지")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(calls).toBe(1) - expect(output.parts).toHaveLength(2) - const forkVisibleParts = (output.parts as TextPartLike[]).filter( - (part) => part.type === "text" && part.synthetic !== true && part.ignored !== true, - ) - expect(forkVisibleParts.map((part) => part.id)).toEqual(["p1"]) - expect((output.parts[0] as TextPartLike).text).toBe("새 메시지\n\n→ EN: EN:새 메시지") - expect((output.parts[0] as TextPartLike).metadata?.translate_en).toBe("EN:새 메시지") - expect((output.parts[1] as TextPartLike).metadata?.translate_role).toBe("llm_only_translation") - }) - - test("undo resend reuses the inline English translation without exposing the original text", async () => { - let calls = 0 - const hooks = createHooks( - { client: fakeClient([]), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const firstOutput = { message: { id: "msg_first" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, firstOutput as never) - const restoredText = (firstOutput.parts[0] as TextPartLike).text ?? "" - expect(restoredText).toContain("안녕\n\n→ EN: EN:안녕") - expect(restoredText).toContain("✓ Translation mode enabled") - - const resendOutput = { message: { id: "msg_resend" }, parts: [textPart("p2", restoredText)] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, resendOutput as never) - - expect(calls).toBe(1) - expect(resendOutput.parts).toHaveLength(2) - expect((resendOutput.parts[0] as TextPartLike).text).toBe(restoredText) - expect((resendOutput.parts[0] as TextPartLike).metadata?.translate_en).toBe("EN:안녕") - expect((resendOutput.parts[0] as TextPartLike).metadata?.translate_source_hash).toBe(hashText("안녕")) - expect((resendOutput.parts[1] as TextPartLike).text).toBe("EN:안녕") - expect((resendOutput.parts[1] as TextPartLike).metadata?.translate_role).toBe("llm_only_translation") - - const transformOutput = { - messages: [storedMessage((resendOutput.parts as TextPartLike[]).map((part) => ({ ...part })))], - } - await hooks["experimental.chat.messages.transform"]!({} as never, transformOutput as never) - - const modelVisibleText = transformOutput.messages[0].parts - .filter((part) => part.type === "text" && part.ignored !== true) - .map((part) => part.text) - expect(modelVisibleText).toEqual(["EN:안녕"]) - }) - - test("untranslated root session without trigger remains inactive", async () => { - let calls = 0 - const hooks = createHooks( - { client: fakeClient([storedMessage([textPart("old", "previous")])]), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "새 메시지")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(calls).toBe(0) - expect(output.parts).toHaveLength(1) - }) -}) diff --git a/test/activation/e2e.test.ts b/test/activation/e2e.test.ts deleted file mode 100644 index c24ea78..0000000 --- a/test/activation/e2e.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import type { MessageWithPartsLike, PluginClientLike, TextPartLike } from "../../src/constants" -import { composeTranslatedAssistantText, composeTranslationFailureText } from "../../src/formatting" -import { activeStateMetadata, textPart } from "../translator/helpers" - -function makeClient(storedMessages: MessageWithPartsLike[], logs: string[] = []): PluginClientLike { - const assistantMessage: MessageWithPartsLike = { - info: { id: "msg_assistant", sessionID: "ses_1", role: "assistant" }, - parts: [textPart("assistant", "Done")], - } - - return { - session: { - get: async () => ({ data: { id: "ses_1", parentID: null } }), - messages: async () => ({ data: storedMessages }), - message: async () => ({ data: assistantMessage }), - }, - provider: { list: async () => ({ data: { all: [] } }) }, - auth: { set: async () => ({ data: {} }) }, - app: { - log: async (input) => { - logs.push(input.body.message) - return { data: {} } - }, - }, - } -} - -describe("translation hook E2E flow", () => { - beforeEach(() => { - __resetActivationCacheForTest() - }) - - test("activates, transforms history, translates assistant text, and restores question output", async () => { - const storedMessages: MessageWithPartsLike[] = [] - const calls: string[] = [] - const hooks = createHooks( - { client: makeClient(storedMessages), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(`${direction}:${text}`) - return direction === "inbound" ? `EN:${text}` : `KO:${text}` - }, - translateTexts: async ({ texts, direction }) => { - calls.push(`${direction}:${texts.join("|")}`) - return texts.map((text) => (direction === "inbound" ? `EN:${text}` : `KO:${text}`)) - }, - }, - }, - ) - - const chatOutput = { message: { id: "msg_user" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, chatOutput as never) - - expect((chatOutput.parts[0] as TextPartLike).text).toContain("안녕\n\n→ EN: EN:안녕") - expect((chatOutput.parts[1] as TextPartLike).text).toBe("EN:안녕") - expect((chatOutput.parts[2] as TextPartLike).metadata?.translate_role).toBe("activation_banner") - - const transformOutput = { - messages: [ - { info: { id: "msg_user", sessionID: "ses_1", role: "user" }, parts: chatOutput.parts }, - { - info: { id: "msg_assistant", sessionID: "ses_1", role: "assistant" }, - parts: [ - textPart("a1", composeTranslatedAssistantText("English answer", "Translation (Korean)", "한국어 답변")), - { ...textPart("file", "ignored"), type: "file" }, - ], - }, - { info: { id: "msg_tool", sessionID: "ses_1", role: "tool" }, parts: [textPart("tool", "tool output")] }, - ], - } - - await hooks["experimental.chat.messages.transform"]!({} as never, transformOutput as never) - - expect((transformOutput.messages[0].parts[0] as TextPartLike).ignored).toBe(true) - expect((transformOutput.messages[0].parts[1] as TextPartLike).text).toBe("EN:안녕") - expect((transformOutput.messages[1].parts[0] as TextPartLike).text).toBe("English answer") - expect((transformOutput.messages[2].parts[0] as TextPartLike).text).toBe("tool output") - - const completeOutput = { text: "English follow-up" } - await hooks["experimental.text.complete"]!( - { sessionID: "ses_1", messageID: "msg_assistant" } as never, - completeOutput, - ) - expect(completeOutput.text).toContain("English follow-up") - expect(completeOutput.text).toContain("KO:English follow-up") - - const args = { - questions: [ - { - question: "Proceed?", - header: "Confirm", - options: [{ label: "Yes", description: "Continue" }], - custom: true, - }, - ], - } - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_e2e" }, { args } as never) - expect(args.questions[0].question).toBe("KO:Proceed?") - - const questionOutput = { - output: `User has answered your questions: "KO:Proceed?"="직접 답변". You can now continue with the user's answers in mind.`, - metadata: { answers: [["직접 답변"]] }, - } - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_e2e", args } as never, - questionOutput as never, - ) - - expect(questionOutput.output).toContain('"Proceed?"="EN:직접 답변"') - expect(calls).toEqual([ - "inbound:안녕", - "outbound:English follow-up", - "outbound:Proceed?|Confirm|Yes|Continue", - "inbound:직접 답변", - ]) - }) - - test("assistant completion failures append the failure trailer and log the error", async () => { - const logs: string[] = [] - const storedMessages = [ - { - info: { id: "msg_banner", sessionID: "ses_1", role: "user" }, - parts: [textPart("banner", "banner", { metadata: activeStateMetadata("banner") })], - }, - ] - const hooks = createHooks( - { client: makeClient(storedMessages, logs), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async () => { - throw new Error("outbound translator unavailable") - }, - }, - }, - ) - const output = { text: "English text" } - - await hooks["experimental.text.complete"]!({ sessionID: "ses_1", messageID: "msg_assistant" } as never, output) - - expect(output.text).toBe(composeTranslationFailureText("English text")) - expect(logs).toEqual(["outbound translator unavailable"]) - }) -}) diff --git a/test/activation/helpers.ts b/test/activation/helpers.ts deleted file mode 100644 index 350adba..0000000 --- a/test/activation/helpers.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { MessageWithPartsLike, PluginClientLike, TextPartLike, TranslateState } from "../../src/constants" - -export function textPart(id: string, text: string, extra: Partial = {}): TextPartLike { - return { - id, - sessionID: "ses_1", - messageID: "msg_new", - type: "text", - text, - ...extra, - } -} - -export function filePart(id = "file_1"): TextPartLike { - return { - id, - sessionID: "ses_1", - messageID: "msg_new", - type: "file", - } -} - -export function makeState(): TranslateState { - return { - translate_enabled: true, - translate_user_lang: "Korean", - translate_llm_lang: "English", - translate_nonce: "0123456789abcdef0123456789abcdef", - } -} - -export function storedMessage(parts: TextPartLike[], role = "user"): MessageWithPartsLike { - return { - info: { - id: `msg_${role}`, - sessionID: "ses_1", - role, - }, - parts, - } -} - -export function fakeClient(storedMessages: MessageWithPartsLike[], parentID: string | null = null): PluginClientLike { - return { - session: { - get: async () => ({ id: "ses_1", parentID }), - messages: async () => storedMessages, - message: async (input) => { - const messageID = "messageID" in input ? input.messageID : input.path.messageID - return storedMessages.find((message) => message.info.id === messageID) ?? storedMessages[0] - }, - }, - provider: { - list: async () => ({ all: [] }), - }, - auth: { - set: async () => undefined, - }, - app: { - log: async () => undefined, - }, - } -} - -export function countingClient( - storedMessages: MessageWithPartsLike[], - parentID: string | null = null, - messageResult?: MessageWithPartsLike, -): { client: PluginClientLike; calls: { get: number; messages: number; message: number } } { - const calls = { get: 0, messages: 0, message: 0 } - const client: PluginClientLike = { - session: { - get: async () => { - calls.get += 1 - return { id: "ses_1", parentID } - }, - messages: async () => { - calls.messages += 1 - return storedMessages - }, - message: async (input) => { - calls.message += 1 - const messageID = "messageID" in input ? input.messageID : input.path.messageID - return messageResult ?? storedMessages.find((message) => message.info.id === messageID) ?? storedMessages[0] - }, - }, - provider: { list: async () => ({ all: [] }) }, - auth: { set: async () => undefined }, - app: { log: async () => undefined }, - } - return { client, calls } -} diff --git a/test/activation/parts.test.ts b/test/activation/parts.test.ts deleted file mode 100644 index 9fbdce3..0000000 --- a/test/activation/parts.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import type { TextPartLike } from "../../src/constants" -import { fakeClient, filePart, textPart } from "./helpers" - -describe("activation part ordering", () => { - beforeEach(() => { - __resetActivationCacheForTest() - }) - - test("multi-part ordering stays exact on activation turn", async () => { - const hooks = createHooks( - { client: fakeClient([]), directory: "/workspace" } as never, - { lang: "Korean", model: "anthropic/claude-haiku-4-5" }, - { translator: { translateText: async ({ text }) => `EN:${text}` } }, - ) - - const output = { - message: { id: "msg_new" }, - parts: [textPart("p1", "$en 첫번째"), filePart(), textPart("p2", "두번째")], - } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect(output.parts).toHaveLength(6) - expect((output.parts[0] as TextPartLike).text).toContain("첫번째\n\n→ EN: EN:첫번째") - expect((output.parts[0] as TextPartLike).text).toContain("✓ Translation mode enabled") - expect((output.parts[1] as TextPartLike).text).toBe("EN:첫번째") - expect((output.parts[1] as TextPartLike).metadata?.translate_role).toBe("llm_only_translation") - expect((output.parts[2] as TextPartLike).type).toBe("file") - expect((output.parts[3] as TextPartLike).text).toBe("두번째\n\n→ EN: EN:두번째") - expect((output.parts[4] as TextPartLike).text).toBe("EN:두번째") - expect((output.parts[5] as TextPartLike).metadata?.translate_role).toBe("activation_banner") - expect((output.parts[1] as TextPartLike).metadata?.translate_part_index).toBe(0) - expect((output.parts[4] as TextPartLike).metadata?.translate_part_index).toBe(1) - }) - - test("synthetic user parts are skipped during inbound translation", async () => { - let calls = 0 - const hooks = createHooks( - { client: fakeClient([]), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { - message: { id: "msg_new" }, - parts: [ - textPart("p1", "$en 첫번째"), - textPart("p2", "compaction marker", { - synthetic: true, - ignored: true, - metadata: { compaction_continue: true }, - }), - textPart("p3", "두번째"), - ], - } - - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - expect(calls).toBe(2) - expect((output.parts[2] as TextPartLike).text).toBe("compaction marker") - expect((output.parts[2] as TextPartLike).synthetic).toBe(true) - }) -}) diff --git a/test/activation/trigger-state.test.ts b/test/activation/trigger-state.test.ts deleted file mode 100644 index 7f0ce79..0000000 --- a/test/activation/trigger-state.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { - __resetActivationCacheForTest, - createHooks, - extractStoredState, - findTriggerMatch, - stripTriggerKeyword, -} from "../../src/activation" -import { hashText } from "../../src/translator" -import { fakeClient, makeState, storedMessage, textPart } from "./helpers" - -describe("activation trigger and state", () => { - beforeEach(() => { - __resetActivationCacheForTest() - }) - - test("keyword stripping preserves exact examples", () => { - expect(stripTriggerKeyword("$en hello", "$en", 0)).toBe("hello") - expect(stripTriggerKeyword("hello $en world", "$en", 6)).toBe("hello world") - expect(stripTriggerKeyword("hello\n$en world", "$en", 6)).toBe("hello\nworld") - expect(stripTriggerKeyword("hello $en\nworld", "$en", 6)).toBe("hello\nworld") - expect(stripTriggerKeyword("literal $en and trigger $en", "$en", 8)).toBe("literal and trigger $en") - }) - - test("multiple keywords match as a disjunction", () => { - const match = findTriggerMatch([textPart("p1", "hello $tr world")], ["$en", "$tr"]) - expect(match?.keyword).toBe("$tr") - }) - - test("metadata round-trips through banner and user-part fallback", () => { - const state = makeState() - const withBanner = extractStoredState([ - storedMessage([ - textPart("banner", "banner", { - synthetic: true, - ignored: true, - metadata: { ...state, translate_role: "activation_banner" }, - }), - ]), - ]) - expect(withBanner).toEqual(state) - - const withFallback = extractStoredState([ - storedMessage([ - textPart("user", "안녕", { - metadata: { - ...state, - translate_en: "hello", - translate_source_hash: hashText("안녕"), - }, - }), - ]), - ]) - expect(withFallback).toEqual(state) - }) - - test("OPENCODE_TRANSLATE_DISABLE=1 returns an empty hook map", () => { - const previous = process.env.OPENCODE_TRANSLATE_DISABLE - process.env.OPENCODE_TRANSLATE_DISABLE = "1" - try { - const hooks = createHooks({ client: fakeClient([]), directory: "/workspace" } as never, { - model: "anthropic/claude-haiku-4-5", - lang: "Korean", - }) - expect(hooks).toEqual({}) - expect(hooks["chat.message"]).toBeUndefined() - expect(hooks["experimental.chat.messages.transform"]).toBeUndefined() - expect(hooks["experimental.text.complete"]).toBeUndefined() - } finally { - if (previous === undefined) delete process.env.OPENCODE_TRANSLATE_DISABLE - else process.env.OPENCODE_TRANSLATE_DISABLE = previous - } - }) -}) diff --git a/test/anthropic-oauth.test.ts b/test/anthropic-oauth.test.ts deleted file mode 100644 index e929983..0000000 --- a/test/anthropic-oauth.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { - buildBillingHeaderValue, - buildOAuthSystem, - CLAUDE_CLI_USER_AGENT, - CLAUDE_CODE_IDENTITY, - isAnthropicMessagesRequest, - mergeBetaHeaders, - rewriteMessagesBody, - rewriteMessagesURL, - setOAuthHeaders, -} from "../src/anthropic-oauth" - -describe("anthropic-oauth", () => { - test("setOAuthHeaders writes the claude-cli fingerprint and drops x-api-key", () => { - const headers = new Headers({ "x-api-key": "leftover" }) - setOAuthHeaders(headers, "sk-test-access") - expect(headers.get("authorization")).toBe("Bearer sk-test-access") - expect(headers.get("user-agent")).toBe(CLAUDE_CLI_USER_AGENT) - expect(headers.get("x-api-key")).toBeNull() - expect(headers.get("anthropic-beta")).toContain("oauth-2025-04-20") - expect(headers.get("anthropic-beta")).toContain("interleaved-thinking-2025-05-14") - }) - - test("mergeBetaHeaders preserves incoming betas and deduplicates", () => { - const headers = new Headers({ "anthropic-beta": "oauth-2025-04-20, extra-beta" }) - const merged = mergeBetaHeaders(headers) - const values = merged.split(",").map((value) => value.trim()) - expect(new Set(values).size).toBe(values.length) - expect(values).toContain("oauth-2025-04-20") - expect(values).toContain("interleaved-thinking-2025-05-14") - expect(values).toContain("extra-beta") - }) - - test("rewriteMessagesURL adds beta=true on /v1/messages but not elsewhere", () => { - const messages = new URL("https://api.anthropic.com/v1/messages") - rewriteMessagesURL(messages) - expect(messages.searchParams.get("beta")).toBe("true") - - const other = new URL("https://api.anthropic.com/v1/models") - rewriteMessagesURL(other) - expect(other.searchParams.get("beta")).toBeNull() - }) - - test("isAnthropicMessagesRequest matches only /v1/messages", () => { - expect(isAnthropicMessagesRequest(new URL("https://api.anthropic.com/v1/messages"))).toBe(true) - expect(isAnthropicMessagesRequest(new URL("https://api.anthropic.com/v1/messages?beta=true"))).toBe(true) - expect(isAnthropicMessagesRequest(new URL("https://api.anthropic.com/v1/models"))).toBe(false) - }) - - test("buildOAuthSystem injects billing header, identity, then existing blocks", () => { - const messages = [{ role: "user", content: "Translate: 안녕하세요" }] - const system = buildOAuthSystem("Original translator prompt", messages) - expect(system[0].text.startsWith("x-anthropic-billing-header:")).toBe(true) - expect(system[0].text).toContain("cc_entrypoint=sdk-cli") - expect(system[0].text).toContain("cch=") - expect(system[1].text).toBe(CLAUDE_CODE_IDENTITY) - expect(system[2].text).toBe("Original translator prompt") - }) - - test("buildOAuthSystem deduplicates an existing Claude Code identity block", () => { - const messages = [{ role: "user", content: "Hello" }] - const system = buildOAuthSystem( - [ - { type: "text", text: CLAUDE_CODE_IDENTITY }, - { type: "text", text: "translator prompt" }, - ], - messages, - ) - const identityCount = system.filter((block) => block.text === CLAUDE_CODE_IDENTITY).length - expect(identityCount).toBe(1) - expect(system.at(-1)?.text).toBe("translator prompt") - }) - - test("buildOAuthSystem normalizes object and mixed-array system blocks", () => { - const objectSystem = buildOAuthSystem({ cache_control: { type: "ephemeral" } }, undefined) - expect(objectSystem[2]).toEqual({ cache_control: { type: "ephemeral" }, type: "text", text: "" }) - - const arraySystem = buildOAuthSystem(["plain", { text: "typed" }, { type: "image" }, 123], undefined) - expect(arraySystem.slice(2)).toEqual([ - { type: "text", text: "plain" }, - { text: "typed", type: "text" }, - ]) - }) - - test("buildBillingHeaderValue is deterministic for the same first user message", () => { - const messages = [{ role: "user", content: "Translate: 안녕" }] - const a = buildBillingHeaderValue(messages) - const b = buildBillingHeaderValue(messages) - expect(a).toBe(b) - expect(a).toContain("cc_entrypoint=sdk-cli") - expect(a).toContain("cch=") - }) - - test("buildBillingHeaderValue extracts the first text block from array content", () => { - const value = buildBillingHeaderValue([ - { role: "assistant", content: "ignored" }, - { role: "user", content: [{ type: "image" }, { type: "text", text: "Translate this" }] }, - ]) - - expect(value).toBe(buildBillingHeaderValue([{ role: "user", content: "Translate this" }])) - expect(buildBillingHeaderValue(undefined)).toContain("cch=") - }) - - test("rewriteMessagesBody rewrites system[] and preserves model/messages", () => { - const body = JSON.stringify({ - model: "claude-opus-4-7", - max_tokens: 128, - system: "translator prompt", - messages: [{ role: "user", content: "Translate: 안녕" }], - }) - const parsed = JSON.parse(rewriteMessagesBody(body)) as { - model: string - max_tokens: number - system: Array<{ type: string; text: string }> - messages: unknown[] - } - - expect(parsed.model).toBe("claude-opus-4-7") - expect(parsed.max_tokens).toBe(128) - expect(Array.isArray(parsed.system)).toBe(true) - expect(parsed.system[0].text.startsWith("x-anthropic-billing-header:")).toBe(true) - expect(parsed.system[1].text).toBe(CLAUDE_CODE_IDENTITY) - expect(parsed.system[2].text).toBe("translator prompt") - expect(parsed.messages).toHaveLength(1) - }) - - test("rewriteMessagesBody returns the original string if JSON parsing fails", () => { - expect(rewriteMessagesBody("not json")).toBe("not json") - }) -}) diff --git a/test/auth/codex.test.ts b/test/auth/codex.test.ts deleted file mode 100644 index a3d62b2..0000000 --- a/test/auth/codex.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { rewriteOpenAICodexBody } from "../../src/auth/codex-request" -import { convertCodexSSEToJSON } from "../../src/auth/codex-response" - -describe("OpenAI Codex request rewriting", () => { - test("passes through bodies that cannot be rewritten", () => { - expect(rewriteOpenAICodexBody(undefined)).toEqual({ body: undefined, originalStream: false }) - expect(rewriteOpenAICodexBody("{not json")).toEqual({ body: "{not json", originalStream: false }) - - const arrayBody = JSON.stringify([]) - expect(rewriteOpenAICodexBody(arrayBody)).toEqual({ body: arrayBody, originalStream: false }) - - const streamOnly = JSON.stringify({ stream: true }) - expect(rewriteOpenAICodexBody(streamOnly)).toEqual({ body: streamOnly, originalStream: true }) - }) - - test("normalizes mixed Responses input into Codex message items", () => { - const rewritten = rewriteOpenAICodexBody( - JSON.stringify({ - instructions: "base instructions", - stream: false, - input: [ - { role: "system", content: [{ text: "system instructions" }, { type: "image" }] }, - { role: "developer", content: "developer instructions" }, - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "hello" }, - { type: "input_image", image_url: "data:image/png;base64,abc" }, - { type: "other", text: "fallback text" }, - { type: "other" }, - ], - }, - { role: "assistant", content: [{ type: "input_text", text: "answer" }] }, - { role: "user", content: { not: "array" } }, - "passthrough", - ], - include: ["existing.include", 123], - tools: [{ type: "function" }], - tool_choice: "none", - parallel_tool_calls: true, - max_output_tokens: 42, - }), - ) - - const parsed = JSON.parse(String(rewritten.body)) as Record - expect(rewritten.originalStream).toBe(false) - expect(parsed.instructions).toBe("base instructions\n\nsystem instructions\n\ndeveloper instructions") - expect(parsed.input).toEqual([ - { - type: "message", - role: "user", - content: [ - { type: "input_text", text: "hello" }, - { type: "input_image", image_url: "data:image/png;base64,abc" }, - { type: "input_text", text: "fallback text" }, - ], - }, - { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, - "passthrough", - ]) - expect(parsed.include).toEqual(["existing.include", "reasoning.encrypted_content"]) - expect(parsed.tools).toEqual([{ type: "function" }]) - expect(parsed.tool_choice).toBe("none") - expect(parsed.parallel_tool_calls).toBe(true) - expect(parsed.store).toBe(false) - expect(parsed.stream).toBe(true) - expect(parsed.max_output_tokens).toBeUndefined() - }) -}) - -describe("OpenAI Codex SSE conversion", () => { - test("normalizes output items from SSE into JSON responses", async () => { - const response = await convertCodexSSEToJSON( - new Response( - [ - "data: {not json}\n\n", - `data: ${JSON.stringify({ type: "response.output_item.done", item: "bad" })}\n\n`, - `data: ${JSON.stringify({ - type: "response.output_item.done", - item: { - type: "message", - role: "assistant", - content: [ - { type: "output_text", text: "hello", annotations: [{ type: "url" }] }, - { type: "output_text", text: 123 }, - { type: "input_text", text: "ignored" }, - ], - }, - })}\n\n`, - `data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "reasoning", summary: [] } })}\n\n`, - "data: [DONE]\n\n", - ].join(""), - { status: 201, statusText: "Created", headers: { "Content-Type": "text/event-stream" } }, - ), - ) - - expect(response.status).toBe(201) - expect(response.headers.get("content-type")).toContain("application/json") - expect(await response.json()).toEqual({ - id: "resp_opencode_translate", - output: [ - { - type: "message", - id: "msg_opencode_translate_1", - role: "assistant", - content: [{ type: "output_text", text: "hello", annotations: [{ type: "url" }] }], - }, - { type: "reasoning", summary: [] }, - ], - }) - }) - - test("returns the original SSE response when no Codex events are found", async () => { - const response = await convertCodexSSEToJSON( - new Response("event: ping\ndata: \n\n", { - status: 202, - statusText: "Accepted", - headers: { "Content-Type": "text/event-stream", "x-test": "kept" }, - }), - ) - - expect(response.status).toBe(202) - expect(response.headers.get("content-type")).toBe("text/event-stream") - expect(response.headers.get("x-test")).toBe("kept") - expect(await response.text()).toBe("event: ping\ndata: \n\n") - }) -}) diff --git a/test/auth/credentials.test.ts b/test/auth/credentials.test.ts deleted file mode 100644 index 40f6279..0000000 --- a/test/auth/credentials.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { __resetAuthCachesForTest, createCredentialResolver } from "../../src/auth" -import { fakeClient } from "./helpers" - -describe("auth credentials", () => { - beforeEach(() => { - __resetAuthCachesForTest() - process.env.OPENCODE_AUTH_CONTENT = "{}" - }) - - afterEach(() => { - delete process.env.OPENCODE_AUTH_CONTENT - }) - - test("credential caches are isolated per resolver", async () => { - const first = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "api", env: ["ANTHROPIC_API_KEY"], key: "first-key" }]), - ) - const second = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "api", env: ["ANTHROPIC_API_KEY"], key: "second-key" }]), - ) - - expect((await first.resolve("anthropic/claude-haiku-4-5")).apiKey).toBe("first-key") - expect((await second.resolve("anthropic/claude-haiku-4-5")).apiKey).toBe("second-key") - }) - - test("env provider keys are used when present", async () => { - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "env", env: ["ANTHROPIC_API_KEY"], key: "env-key" }]), - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - expect(resolved.apiKey).toBe("env-key") - }) - - test("dummy and empty keys are treated as no key and fall through", async () => { - const sentinelResolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "api", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - ) - expect((await sentinelResolver.resolve("anthropic/claude-haiku-4-5")).mode).toBe("default") - - __resetAuthCachesForTest() - const emptyResolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "api", env: ["ANTHROPIC_API_KEY"], key: "" }]), - ) - expect((await emptyResolver.resolve("anthropic/claude-haiku-4-5")).mode).toBe("default") - }) - - test("multi-var providers fall back to their own env discovery", async () => { - const model = "amazon-bedrock/us.anthropic.claude-3-5-sonnet-20241022-v2:0" - const resolver = createCredentialResolver( - fakeClient([ - { - id: "amazon-bedrock", - source: "env", - env: ["AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], - }, - ]), - ) - - const resolved = await resolver.resolve(model) - expect(resolved.mode).toBe("default") - expect(resolved.apiKey).toBeUndefined() - }) - - test("OPENCODE_AUTH_CONTENT enables OAuth reuse without touching the filesystem", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - expect(resolved.mode).toBe("oauth") - expect(resolved.apiKey).toBe("") - expect(typeof resolved.fetch).toBe("function") - }) - - test("OpenAI OAuth auth wins over resolved API keys", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "env", env: ["OPENAI_API_KEY"], key: "provider-key" }]), - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - expect(resolved.mode).toBe("oauth") - expect(resolved.apiKey).toBe("") - expect(typeof resolved.fetch).toBe("function") - }) - - test("OpenAI OAuth auth can replace a cached API-key credential", async () => { - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "env", env: ["OPENAI_API_KEY"], key: "provider-key" }]), - ) - - expect((await resolver.resolve("openai/gpt-5.5")).apiKey).toBe("provider-key") - - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - - const resolved = await resolver.resolve("openai/gpt-5.5") - expect(resolved.mode).toBe("oauth") - expect(resolved.apiKey).toBe("") - expect(typeof resolved.fetch).toBe("function") - }) - - test("OpenAI falls back to resolved provider key if the OAuth record disappears before fetch setup", async () => { - delete process.env.OPENCODE_AUTH_CONTENT - let reads = 0 - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "env", env: ["OPENAI_API_KEY"], key: "provider-key" }]), - { - readFile: async () => { - reads += 1 - if (reads === 1) { - return JSON.stringify({ - openai: { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3600_000, - }, - }) - } - return "{}" - }, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - expect(resolved.mode).toBe("apiKey") - expect(resolved.apiKey).toBe("provider-key") - }) - - test("auth file API records are used when provider keys are absent", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "api", key: "stored-key" }, - }) - - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "api", env: ["ANTHROPIC_API_KEY"] }]), - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - expect(resolved.mode).toBe("apiKey") - expect(resolved.apiKey).toBe("stored-key") - }) - - test("OAuth records without request adapters can be used as bearer keys", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - "custom-provider": { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3600_000, - }, - }) - - const resolver = createCredentialResolver( - fakeClient([{ id: "custom-provider", source: "env", env: ["CUSTOM_PROVIDER_API_KEY"] }]), - ) - - const resolved = await resolver.resolve("custom-provider/model") - expect(resolved.mode).toBe("oauth") - expect(resolved.apiKey).toBe("access-token") - expect(resolved.fetch).toBeUndefined() - }) - - test("provider-list failures fall through to default resolution instead of throwing", async () => { - const resolver = createCredentialResolver({ - ...fakeClient([]), - provider: { - list: async () => { - throw new Error("unavailable") - }, - }, - }) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - expect(resolved.mode).toBe("default") - }) - - test("missing credential detection covers common provider error wording", () => { - const resolver = createCredentialResolver(fakeClient([])) - - expect(resolver.isMissingCredentialError(new Error("API key missing"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("api-key required"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("missing credentials"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("missing authentication"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("missing auth token"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("no auth configured"))).toBe(true) - expect(resolver.isMissingCredentialError(new Error("permission denied"))).toBe(false) - }) - - test("authUnavailable falls back to the generic API-key hint", () => { - const resolver = createCredentialResolver(fakeClient([])) - - expect(resolver.authUnavailable("custom-provider").message).toContain("Set the provider's API key env var") - }) -}) diff --git a/test/auth/helpers.ts b/test/auth/helpers.ts deleted file mode 100644 index c4d1ae3..0000000 --- a/test/auth/helpers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { PluginClientLike, ProviderInfo } from "../../src/constants" - -export function fakeClient(providers: ProviderInfo[], authSetCalls: unknown[] = []): PluginClientLike { - return { - session: { - get: async () => ({ id: "ses_1", parentID: null }), - messages: async () => [], - message: async () => ({ info: { id: "msg_1", sessionID: "ses_1", role: "assistant" }, parts: [] }), - }, - provider: { - list: async () => ({ all: providers }), - }, - auth: { - set: async (input) => { - authSetCalls.push(input) - return undefined - }, - }, - app: { - log: async () => undefined, - }, - } -} diff --git a/test/auth/oauth-fetch.test.ts b/test/auth/oauth-fetch.test.ts deleted file mode 100644 index e62bce3..0000000 --- a/test/auth/oauth-fetch.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test" -import { __resetAuthCachesForTest, createCredentialResolver } from "../../src/auth" -import { fakeClient } from "./helpers" - -beforeEach(() => { - __resetAuthCachesForTest() - process.env.OPENCODE_AUTH_CONTENT = "{}" -}) - -afterEach(() => { - delete process.env.OPENCODE_AUTH_CONTENT -}) - -test("Anthropic OAuth request headers are adapted correctly", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - let finalUrl = "" - let finalHeaders = new Headers() - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async (input, init) => { - finalUrl = input instanceof URL ? input.href : String(input) - finalHeaders = new Headers(init?.headers) - return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - await resolved.fetch!("https://api.anthropic.com/v1/messages", { headers: { "x-api-key": "dummy" } }) - - expect(finalUrl).toContain("?beta=true") - expect(finalHeaders.get("Authorization")).toBe("Bearer access-token") - expect(finalHeaders.get("anthropic-beta")).toContain("oauth-2025-04-20") - expect(finalHeaders.get("anthropic-version")).toBe("2023-06-01") - expect(finalHeaders.get("x-api-key")).toBeNull() -}) - -test("Anthropic OAuth rewrites messages request bodies", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - let finalBody = "" - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async (_input, init) => { - finalBody = String(init?.body) - return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - await resolved.fetch!("https://api.anthropic.com/v1/messages", { - method: "POST", - body: JSON.stringify({ system: "translator", messages: [{ role: "user", content: "안녕" }] }), - }) - - const parsed = JSON.parse(finalBody) as { system: Array<{ text: string }> } - expect(parsed.system[0].text).toContain("x-anthropic-billing-header") - expect(parsed.system[1].text).toContain("Claude agent") - expect(parsed.system[2].text).toBe("translator") -}) - -test("OpenAI OAuth rewrites Responses requests to Codex request shape", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3600_000, - accountId: "acct_1", - }, - }) - let finalUrl = "" - let finalHeaders = new Headers() - let finalBody = "" - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "custom", env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async (input, init) => { - finalUrl = input instanceof URL ? input.href : String(input) - finalHeaders = new Headers(init?.headers) - finalBody = String(init?.body) - return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - await resolved.fetch!("https://api.openai.com/v1/responses", { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: "Bearer " }, - body: JSON.stringify({ - model: "gpt-5.5", - input: [ - { role: "developer", content: "translator instructions" }, - { role: "user", content: [{ type: "input_text", text: "\n안녕\n" }] }, - ], - }), - }) - - const parsed = JSON.parse(finalBody) as Record - expect(finalUrl).toBe("https://chatgpt.com/backend-api/codex/responses") - expect(finalHeaders.get("Authorization")).toBe("Bearer access-token") - expect(finalHeaders.get("ChatGPT-Account-Id")).toBe("acct_1") - expect(finalHeaders.get("OpenAI-Beta")).toBe("responses=experimental") - expect(finalHeaders.get("originator")).toBe("codex_cli_rs") - expect(finalHeaders.get("accept")).toBe("text/event-stream") - expect(parsed.instructions).toBe("translator instructions") - expect(parsed.messages).toBeUndefined() - expect(parsed.input).toEqual([ - { type: "message", role: "user", content: [{ type: "input_text", text: "\n안녕\n" }] }, - ]) - expect(parsed.tools).toEqual([]) - expect(parsed.tool_choice).toBe("auto") - expect(parsed.parallel_tool_calls).toBe(false) - expect(parsed.store).toBe(false) - expect(parsed.stream).toBe(true) - expect(parsed.include).toEqual(["reasoning.encrypted_content"]) -}) - -test("OpenAI OAuth converts Codex SSE responses back to JSON for generateText", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "custom", env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async () => - new Response( - [ - `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: "hello" })}\n\n`, - `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: "resp_1", output: [], usage: { input_tokens: 1, output_tokens: 1 } } })}\n\n`, - ].join(""), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ), - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - const response = await resolved.fetch!("https://api.openai.com/v1/responses", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "gpt-5.5", - input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], - }), - }) - - expect(response.headers.get("content-type")).toContain("application/json") - expect(await response.json()).toEqual({ - id: "resp_1", - output: [ - { - type: "message", - id: "msg_opencode_translate_0", - role: "assistant", - content: [{ type: "output_text", text: "hello", annotations: [] }], - }, - ], - usage: { input_tokens: 1, output_tokens: 1 }, - }) -}) - -test("OpenAI OAuth rewrites Chat Completions messages to Codex request shape", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - let finalBody = "" - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "custom", env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async (_input, init) => { - finalBody = String(init?.body) - return new Response("{}", { status: 200 }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - await resolved.fetch!("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "gpt-5.5", - messages: [ - { role: "system", content: "system instructions" }, - { role: "user", content: "안녕" }, - { role: "assistant", content: "hello" }, - ], - }), - }) - - const parsed = JSON.parse(finalBody) as Record - expect(parsed.instructions).toBe("system instructions") - expect(parsed.messages).toBeUndefined() - expect(parsed.input).toEqual([ - { type: "message", role: "user", content: [{ type: "input_text", text: "안녕" }] }, - { type: "message", role: "assistant", content: [{ type: "output_text", text: "hello" }] }, - ]) -}) - -test("OpenAI OAuth leaves non-OpenAI API URLs on the normal fetch path", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: Date.now() + 3600_000 }, - }) - let finalUrl = "" - let finalBody = "" - const resolver = createCredentialResolver( - fakeClient([{ id: "openai", source: "custom", env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async (input, init) => { - finalUrl = input instanceof URL ? input.href : String(input) - finalBody = String(init?.body) - return new Response("plain", { status: 200, headers: { "Content-Type": "text/plain" } }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - const response = await resolved.fetch!("https://api.openai.com/v1/models", { - method: "POST", - body: "original-body", - }) - - expect(finalUrl).toBe("https://api.openai.com/v1/models") - expect(finalBody).toBe("original-body") - expect(await response.text()).toBe("plain") -}) - -test("GitHub Copilot OAuth exchanges a session token and rewrites enterprise hosts", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - "github-copilot": { - type: "oauth", - access: "unused-access", - refresh: "github-refresh-token", - expires: Date.now() + 3600_000, - enterpriseUrl: "https://copilot.example.test:8443", - }, - }) - const calls: Array<{ url: string; headers: Headers }> = [] - const resolver = createCredentialResolver( - fakeClient([ - { - id: "github-copilot", - source: "custom", - env: ["GITHUB_COPILOT_API_KEY"], - key: "opencode-oauth-dummy-key", - }, - ]), - { - packageVersion: "9.9.9", - fetchImpl: async (input, init) => { - const url = input instanceof URL ? input.href : String(input) - calls.push({ url, headers: new Headers(init?.headers) }) - if (url === "https://api.github.com/copilot_internal/v2/token") { - return new Response(JSON.stringify({ token: "copilot-session-token" }), { status: 200 }) - } - return new Response("ok", { status: 200 }) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("github-copilot/gpt-4o") - await resolved.fetch!("https://api.githubcopilot.com/chat/completions", { headers: { "x-api-key": "remove-me" } }) - - expect(calls[0].url).toBe("https://api.github.com/copilot_internal/v2/token") - expect(calls[0].headers.get("Authorization")).toBe("token github-refresh-token") - expect(calls[1].url).toBe("https://copilot.example.test:8443/chat/completions") - expect(calls[1].headers.get("Authorization")).toBe("Bearer copilot-session-token") - expect(calls[1].headers.get("Editor-Version")).toBe("opencode-translate/9.9.9") - expect(calls[1].headers.get("Editor-Plugin-Version")).toBe("opencode-translate/9.9.9") - expect(calls[1].headers.get("Copilot-Integration-Id")).toBe("vscode-chat") - expect(calls[1].headers.get("x-api-key")).toBeNull() -}) diff --git a/test/auth/oauth-refresh.test.ts b/test/auth/oauth-refresh.test.ts deleted file mode 100644 index b3a908c..0000000 --- a/test/auth/oauth-refresh.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { afterEach, beforeEach, expect, test } from "bun:test" -import { __resetAuthCachesForTest, createCredentialResolver } from "../../src/auth" -import { exchangeCopilotToken, refreshAnthropic, refreshOpenAI } from "../../src/auth/refresh" -import { fakeClient } from "./helpers" - -beforeEach(() => { - __resetAuthCachesForTest() - process.env.OPENCODE_AUTH_CONTENT = "{}" -}) - -afterEach(() => { - delete process.env.OPENCODE_AUTH_CONTENT -}) - -test("expired OAuth tokens refresh once and persist updated auth", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "old-access", refresh: "old-refresh", expires: Date.now() - 1000 }, - }) - const authSetCalls: unknown[] = [] - let fetchCalls = 0 - const resolver = createCredentialResolver( - fakeClient( - [{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }], - authSetCalls, - ), - { - fetchImpl: async () => { - fetchCalls += 1 - return new Response( - JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 1800 }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("anthropic/claude-haiku-4-5") - expect(resolved.mode).toBe("oauth") - expect(fetchCalls).toBe(1) - expect(authSetCalls).toHaveLength(1) - expect(authSetCalls[0]).toEqual({ - path: { id: "anthropic" }, - body: { - type: "oauth", - access: "new-access", - refresh: "new-refresh", - expires: expect.any(Number), - accountId: undefined, - enterpriseUrl: undefined, - }, - }) -}) - -test("expired OpenAI OAuth tokens refresh with Codex form body", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { - type: "oauth", - access: "old-access", - refresh: "old-refresh", - expires: Date.now() - 1000, - accountId: "acct_1", - }, - }) - const authSetCalls: unknown[] = [] - let refreshBody = "" - const resolver = createCredentialResolver( - fakeClient( - [{ id: "openai", source: "custom", env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }], - authSetCalls, - ), - { - fetchImpl: async (_input, init) => { - refreshBody = String(init?.body) - return new Response( - JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 1800 }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ) - }, - sleep: async () => undefined, - }, - ) - - const resolved = await resolver.resolve("openai/gpt-5.5") - expect(resolved.mode).toBe("oauth") - expect(refreshBody).toBe("grant_type=refresh_token&refresh_token=old-refresh&client_id=app_EMoamEEZ73f0CkXaXp7hrann") - expect(authSetCalls[0]).toEqual({ - path: { id: "openai" }, - body: { - type: "oauth", - access: "new-access", - refresh: "new-refresh", - expires: expect.any(Number), - accountId: "acct_1", - enterpriseUrl: undefined, - }, - }) -}) - -test("OAuth refresh is coalesced across concurrent resolves", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "old-access", refresh: "old-refresh", expires: Date.now() - 1000 }, - }) - let fetchCalls = 0 - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async () => { - fetchCalls += 1 - await Promise.resolve() - return new Response( - JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 1800 }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ) - }, - sleep: async () => undefined, - }, - ) - - await Promise.all([resolver.resolve("anthropic/claude-haiku-4-5"), resolver.resolve("anthropic/claude-haiku-4-5")]) - expect(fetchCalls).toBe(1) -}) - -test("OAuth refresh uses the default zero-delay sleeper when no sleep dependency is supplied", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "old-access", refresh: "old-refresh", expires: Date.now() - 1000 }, - }) - let fetchCalls = 0 - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async () => { - fetchCalls += 1 - if (fetchCalls === 1) return new Response("rate limited", { status: 429, headers: { "retry-after": "0" } }) - return new Response(JSON.stringify({ access_token: "new-access", refresh_token: "new-refresh" }), { - status: 200, - }) - }, - }, - ) - - await expect(resolver.resolve("anthropic/claude-haiku-4-5")).resolves.toMatchObject({ mode: "oauth" }) - expect(fetchCalls).toBe(2) -}) - -test("OAuth refresh failures surface the exact error after retries", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - anthropic: { type: "oauth", access: "old-access", refresh: "old-refresh", expires: Date.now() - 1000 }, - }) - let fetchCalls = 0 - const resolver = createCredentialResolver( - fakeClient([{ id: "anthropic", source: "custom", env: ["ANTHROPIC_API_KEY"], key: "opencode-oauth-dummy-key" }]), - { - fetchImpl: async () => { - fetchCalls += 1 - return new Response("{}", { status: 500 }) - }, - sleep: async () => undefined, - }, - ) - - await expect(resolver.resolve("anthropic/claude-haiku-4-5")).rejects.toThrow( - '[opencode-translate:OAUTH_REFRESH_FAILED] Failed to refresh OAuth token for provider "anthropic": HTTP 500. Re-authenticate with "opencode auth login anthropic".', - ) - expect(fetchCalls).toBe(3) -}) - -test("refreshAnthropic wraps invalid JSON and missing token responses", async () => { - await expect( - refreshAnthropic( - { type: "oauth", access: "old", refresh: "refresh", expires: 0 }, - { - fetchImpl: async () => new Response("not json", { status: 200 }), - sleep: async () => undefined, - }, - ), - ).rejects.toThrow('Failed to refresh OAuth token for provider "anthropic"') - - await expect( - refreshAnthropic( - { type: "oauth", access: "old", refresh: "refresh", expires: 0 }, - { - fetchImpl: async () => new Response(JSON.stringify({ access_token: "new-access" }), { status: 200 }), - sleep: async () => undefined, - }, - ), - ).rejects.toThrow("Invalid token response") -}) - -test("refreshOpenAI wraps invalid JSON and missing token responses", async () => { - await expect( - refreshOpenAI( - { type: "oauth", access: "old", refresh: "refresh", expires: 0 }, - { - fetchImpl: async () => new Response("not json", { status: 200 }), - sleep: async () => undefined, - }, - ), - ).rejects.toThrow('Failed to refresh OAuth token for provider "openai"') - - await expect( - refreshOpenAI( - { type: "oauth", access: "old", refresh: "refresh", expires: 0 }, - { - fetchImpl: async () => new Response(JSON.stringify({ refresh_token: "new-refresh" }), { status: 200 }), - sleep: async () => undefined, - }, - ), - ).rejects.toThrow("Invalid token response") -}) - -test("exchangeCopilotToken rejects malformed token responses", async () => { - await expect( - exchangeCopilotToken( - { type: "oauth", access: "old", refresh: "refresh", expires: 0 }, - { - fetchImpl: async () => new Response(JSON.stringify({}), { status: 200 }), - sleep: async () => undefined, - }, - ), - ).rejects.toThrow('Failed to refresh OAuth token for provider "github-copilot": Invalid token response') -}) diff --git a/test/auth/retry-store.test.ts b/test/auth/retry-store.test.ts deleted file mode 100644 index d1773f9..0000000 --- a/test/auth/retry-store.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { withRetry as withAuthRetry } from "../../src/auth/retry" -import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "../../src/auth/store" -import { withRetry as withTranslatorRetry } from "../../src/translator/retry" - -describe("auth and translator retry helpers", () => { - test("auth retry honors retry-after seconds for one 429 retry", async () => { - const sleeps: number[] = [] - let calls = 0 - - const result = await withAuthRetry( - async () => { - calls += 1 - if (calls === 1) { - const error = new Error("rate limited") as Error & { response?: Response } - error.response = new Response("", { status: 429, headers: { "retry-after": "0.25" } }) - throw error - } - return "ok" - }, - { sleep: async (ms) => void sleeps.push(ms) }, - ) - - expect(result).toBe("ok") - expect(calls).toBe(2) - expect(sleeps).toEqual([250]) - }) - - test("auth retry stops after the second 429", async () => { - const sleeps: number[] = [] - const error = new Error("rate limited") as Error & { status?: number; response?: Response } - error.status = 429 - error.response = new Response("", { status: 429, headers: { "retry-after": "0" } }) - - await expect( - withAuthRetry( - async () => { - throw error - }, - { sleep: async (ms) => void sleeps.push(ms) }, - ), - ).rejects.toThrow("rate limited") - expect(sleeps).toEqual([0]) - }) - - test("auth retry uses backoff for statusCode server errors", async () => { - const sleeps: number[] = [] - let calls = 0 - - const result = await withAuthRetry( - async () => { - calls += 1 - if (calls < 3) { - const error = new Error("server failed") as Error & { statusCode?: number } - error.statusCode = 503 - throw error - } - return "recovered" - }, - { sleep: async (ms) => void sleeps.push(ms) }, - ) - - expect(result).toBe("recovered") - expect(sleeps).toEqual([500, 1500]) - }) - - test("auth retry does not retry non-retryable client errors", async () => { - let calls = 0 - const error = new Error("bad request") as Error & { response?: { status: number } } - error.response = { status: 400 } - - await expect( - withAuthRetry( - async () => { - calls += 1 - throw error - }, - { sleep: async () => undefined }, - ), - ).rejects.toThrow("bad request") - expect(calls).toBe(1) - }) - - test("auth retry falls back to message matching and default retry-after delay", async () => { - const sleeps: number[] = [] - let calls = 0 - - const messageMatched = await withAuthRetry( - async () => { - calls += 1 - if (calls === 1) { - const error = new Error("fetch failed") as Error & { response?: { status: string } } - error.response = { status: "not-a-number" } - throw error - } - return "ok" - }, - { sleep: async (ms) => void sleeps.push(ms) }, - ) - expect(messageMatched).toBe("ok") - - let rateLimitCalls = 0 - const rateLimited = await withAuthRetry( - async () => { - rateLimitCalls += 1 - if (rateLimitCalls === 1) { - const error = new Error("rate limited") as Error & { status?: number; response?: Response } - error.status = 429 - error.response = new Response("", { status: 429, headers: { "retry-after": "not-a-date" } }) - throw error - } - return "retried" - }, - { sleep: async (ms) => void sleeps.push(ms) }, - ) - - expect(rateLimited).toBe("retried") - expect(sleeps).toEqual([500, 2000]) - }) - - test("translator retry honors retry-after dates and network errors", async () => { - const sleeps: number[] = [] - let calls = 0 - - const result = await withTranslatorRetry( - async () => { - calls += 1 - if (calls === 1) { - const error = new Error("rate limited") as Error & { response?: Response } - error.response = new Response("", { - status: 429, - headers: { "retry-after": "Thu, 01 Jan 1970 00:00:00 GMT" }, - }) - throw error - } - if (calls === 2) throw new Error("network socket closed") - return "translated" - }, - async (ms) => void sleeps.push(ms), - ) - - expect(result).toBe("translated") - expect(sleeps).toEqual([0, 1500]) - }) - - test("translator retry rethrows after repeated transient failures", async () => { - const sleeps: number[] = [] - - await expect( - withTranslatorRetry( - async () => { - const error = new Error("timeout") as Error & { status?: number } - error.status = 504 - throw error - }, - async (ms) => void sleeps.push(ms), - ), - ).rejects.toThrow("timeout") - expect(sleeps).toEqual([500, 1500]) - }) -}) - -describe("auth store helpers", () => { - afterEach(() => { - delete process.env.OPENCODE_AUTH_CONTENT - delete process.env.XDG_DATA_HOME - }) - - test("normalizeProviderKey and ensureOAuthInfo filter non-OAuth credentials", () => { - expect(normalizeProviderKey(undefined)).toBeUndefined() - expect(normalizeProviderKey("opencode-oauth-dummy-key")).toBeUndefined() - expect(normalizeProviderKey("real-key")).toBe("real-key") - expect(ensureOAuthInfo({ type: "api", key: "api-key" })).toBeUndefined() - expect(ensureOAuthInfo({ type: "oauth", access: "a", refresh: "r", expires: 1 })?.access).toBe("a") - }) - - test("readAuthMap returns undefined for invalid OPENCODE_AUTH_CONTENT", async () => { - process.env.OPENCODE_AUTH_CONTENT = "{not json" - expect(await readAuthMap({})).toBeUndefined() - }) - - test("readAuthMap reads the OpenCode XDG auth file", async () => { - delete process.env.OPENCODE_AUTH_CONTENT - process.env.XDG_DATA_HOME = "/tmp/opencode-translate-test-data" - let seenPath = "" - - const map = await readAuthMap({ - readFile: async (filePath) => { - seenPath = filePath - return JSON.stringify({ anthropic: { type: "oauth", access: "a", refresh: "r", expires: 1 } }) - }, - }) - - expect(seenPath).toBe("/tmp/opencode-translate-test-data/opencode/auth.json") - expect(map?.anthropic?.type).toBe("oauth") - }) - - test("readAuthMap returns undefined for invalid auth files", async () => { - delete process.env.OPENCODE_AUTH_CONTENT - - expect( - await readAuthMap({ - readFile: async () => "{not json", - }), - ).toBeUndefined() - }) - - test("readAuthMap normalizes active auth-v2 accounts", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - version: 2, - active: { anthropic: "acc_1" }, - accounts: { - acc_1: { - serviceID: "anthropic", - credential: { type: "api", key: "api-key", metadata: { resourceName: "resource" } }, - }, - }, - }) - - expect(await readAuthMap({})).toEqual({ - anthropic: { type: "api", key: "api-key", metadata: { resourceName: "resource" } }, - }) - }) - - test("readAuthMap accepts wellknown credentials and ignores invalid entries", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - github: { type: "wellknown", key: "device-key", token: "device-token" }, - invalidOauth: { type: "oauth", access: "a", refresh: "r" }, - invalidType: { type: "other", key: "ignored" }, - }) - - expect(await readAuthMap({})).toEqual({ - github: { type: "wellknown", key: "device-key", token: "device-token" }, - }) - }) - - test("readAuthMap follows xdg-basedir default data directory", async () => { - const seenPaths: string[] = [] - await readAuthMap({ - readFile: async (filePath) => { - seenPaths.push(filePath) - throw new Error("missing") - }, - }) - - expect(seenPaths[0]?.endsWith("/.local/share/opencode/auth.json")).toBe(true) - expect(seenPaths[1]?.endsWith("/.local/share/opencode/auth-v2.json")).toBe(true) - }) -}) diff --git a/test/constants/options.test.ts b/test/constants/options.test.ts index e310a07..e35fff0 100644 --- a/test/constants/options.test.ts +++ b/test/constants/options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getEnvVarHint, parseTranslatorModel, resolveOptions } from "../../src/constants" +import { parseTranslatorModel, resolveOptions } from "../../src/constants" describe("option resolution", () => { test("resolveOptions sanitizes optional user configuration", () => { @@ -61,9 +61,4 @@ describe("option resolution", () => { }) expect(parseTranslatorModel("openai/gpt-5.5")).toEqual({ providerID: "openai", modelID: "gpt-5.5" }) }) - - test("getEnvVarHint uses provider-specific env names when available", () => { - expect(getEnvVarHint({ id: "openai", source: "env", env: ["OPENAI_API_KEY"] })).toBe("OPENAI_API_KEY") - expect(getEnvVarHint(undefined)).toBe("the provider's API key env var") - }) }) diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..1f1dd90 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,80 @@ +import type { Plugin } from "@opencode/plugin" + +export function host(options: Record = {}) { + const values = new Map() + const callbacks = new Map Promise>() + const history: Record }[]> = {} + const children = new Set() + const forks = new Map() + const requests: { model: unknown; prompt: string }[] = [] + let generate = async (_prompt: string) => "translated" + const hook = (domain: string) => async (name: string, callback: (event: unknown) => Promise) => { + callbacks.set(`${domain}.${name}`, callback) + return { + dispose: async () => { + callbacks.delete(`${domain}.${name}`) + }, + } + } + const ctx = { + app: { version: "2.0.3" }, + options: { model: "openai/gpt-5.4-mini", lang: "Korean", ...options }, + session: { + hook: hook("session"), + get: async ({ sessionID }: { sessionID: string }) => ({ + id: sessionID, + parentID: children.has(sessionID) ? "parent" : undefined, + fork: forks.has(sessionID) ? { sessionID: forks.get(sessionID) } : undefined, + }), + context: async ({ sessionID }: { sessionID: string }) => history[sessionID] ?? [], + }, + tool: { hook: hook("tool") }, + storage: { + get: async (key: string) => values.get(key), + set: async (key: string, value: unknown) => { + values.set(key, structuredClone(value)) + }, + remove: async (key: string) => { + values.delete(key) + }, + scan: async ({ prefix, after, limit = 100 }: { prefix: string; after?: string; limit?: number }) => { + const all = [...values] + .filter(([key]) => key.startsWith(prefix) && (!after || key > after)) + .sort(([a], [b]) => a.localeCompare(b)) + const entries = all.slice(0, limit).map(([key, value]) => ({ key, value })) + return { entries, next: all.length > limit ? entries.at(-1)?.key : undefined } + }, + }, + generate: { + text: async (input: { model: unknown; prompt: string }) => { + requests.push(input) + return { text: await generate(input.prompt) } + }, + }, + } as unknown as Plugin.Context + return { + ctx, + values, + callbacks, + children, + forks, + history, + requests, + generate: (impl: typeof generate) => { + generate = impl + }, + async emit(name: string, event: unknown) { + const callback = callbacks.get(name) + if (!callback) throw new Error(`Missing hook ${name}`) + await callback(event) + }, + } +} + +export function prompt(text = "$en 안녕하세요", sessionID = "ses_1") { + return { sessionID, messageID: "msg_1", prompt: { text }, metadata: {} as Record, delivery: "steer" } +} + +export function requestContext(messages: unknown[], sessionID = "ses_1") { + return { sessionID, model: { providerID: "openai", id: "gpt-5.4" }, system: [], messages, options: {} } +} diff --git a/test/index.test.ts b/test/index.test.ts index eea1793..5971f72 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,17 +1,18 @@ -import { afterEach, expect, test } from "bun:test" -import OpencodeTranslate, { OpencodeTranslate as namedPlugin } from "../src/index" +import { expect, test } from "bun:test" +import plugin, { OpencodeTranslate } from "../src/index" +import { host } from "./helpers" -afterEach(() => { - delete process.env.OPENCODE_TRANSLATE_DISABLE -}) - -test("default export and named plugin share the same implementation", async () => { - process.env.OPENCODE_TRANSLATE_DISABLE = "1" - const ctx = { - client: {}, - directory: "/workspace", +test("v2 exports the stable definition and honors the disable flag without validating options", async () => { + expect(plugin).toBe(OpencodeTranslate) + expect(plugin.id).toBe("opencode-translate") + const h = host({ model: "" }) + const previous = process.env.OPENCODE_TRANSLATE_DISABLE + try { + process.env.OPENCODE_TRANSLATE_DISABLE = "1" + expect(await plugin.setup(h.ctx)).toBeUndefined() + expect(h.callbacks.size).toBe(0) + } finally { + if (previous === undefined) delete process.env.OPENCODE_TRANSLATE_DISABLE + else process.env.OPENCODE_TRANSLATE_DISABLE = previous } - - expect(OpencodeTranslate).toBe(namedPlugin) - expect(await OpencodeTranslate(ctx as never, {})).toEqual({}) }) diff --git a/test/native-protocols.test.ts b/test/native-protocols.test.ts new file mode 100644 index 0000000..8031155 --- /dev/null +++ b/test/native-protocols.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "bun:test" +import { LLM, type LLMEvent } from "@opencode/ai" +import { AnthropicMessages } from "@opencode/ai/protocols/anthropic-messages" +import { Gemini } from "@opencode/ai/protocols/gemini" +import { OpenAIChat } from "@opencode/ai/protocols/openai-chat" +import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" +import type { Protocol } from "@opencode/ai/route/protocol" +import { Effect, Schema } from "effect" +import { type Protocol as AdapterProtocol, createAdapter } from "../src/response/protocols" + +// Decode the rewritten wire events with OpenCode's real native protocol parsers, +// rather than asserting only against our own representation of an SSE stream. +async function parse( + protocol: Protocol, + adapterProtocol: AdapterProtocol, + events: unknown[], +) { + const adapter = createAdapter(adapterProtocol, async (text) => `${text}\n\n번역`) + const request = LLM.request({ + model: OpenAIResponses.route.model({ id: "test", provider: "openai" }), + prompt: "Hello", + }) + let state = protocol.stream.initial(request) + const results: LLMEvent[] = [] + for (const input of events) { + for (const frame of await adapter(`data: ${JSON.stringify(input)}`)) { + const data = frame + .split("\n") + .find((line) => line.startsWith("data:"))! + .slice(5) + .trim() + const decoded = Schema.decodeUnknownSync(protocol.stream.event)(data) + const [next, emitted] = await Effect.runPromise(protocol.stream.step(state, decoded)) + state = next + results.push(...emitted) + } + } + if (protocol.stream.onHalt) results.push(...(await Effect.runPromise(protocol.stream.onHalt(state)))) + return results +} + +test("real Responses parser emits the translated text exactly once and finishes", async () => { + const item = { + type: "message", + role: "assistant", + id: "item_1", + status: "completed", + content: [{ type: "output_text", text: "Hello", annotations: [] }], + } + const events = await parse(OpenAIResponses.protocol, "responses", [ + { type: "response.created", response: { id: "resp_1", model: "test", output: [], status: "in_progress" } }, + { type: "response.output_item.added", output_index: 0, item: { ...item, content: [], status: "in_progress" } }, + { type: "response.output_text.delta", item_id: "item_1", output_index: 0, content_index: 0, delta: "Hello" }, + { type: "response.output_text.done", item_id: "item_1", output_index: 0, content_index: 0, text: "Hello" }, + { type: "response.output_item.done", output_index: 0, item }, + { + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + output: [item], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + }, + }, + ]) + expect( + events + .filter((e) => e.type === "text-delta") + .map((e) => e.text) + .join(""), + ).toBe("Hello\n\n번역") + expect(events.some((e) => e.type === "finish")).toBe(true) +}) +test("real Anthropic parser accepts injected deltas before text block completion", async () => { + const events = await parse(AnthropicMessages.protocol, "anthropic", [ + { + type: "message_start", + message: { + id: "msg_1", + type: "message", + role: "assistant", + model: "test", + content: [], + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } }, + { type: "message_stop" }, + ]) + expect( + events + .filter((e) => e.type === "text-delta") + .map((e) => e.text) + .join(""), + ).toBe("Hello\n\n번역") + expect(events.some((e) => e.type === "finish")).toBe(true) +}) +test("real Chat parser accepts translation in a final delta", async () => { + const events = await parse(OpenAIChat.protocol, "chat", [ + { id: "chat_1", choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }] }, + { + id: "chat_1", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 }, + }, + ]) + expect( + events + .filter((e) => e.type === "text-delta") + .map((e) => e.text) + .join(""), + ).toBe("Hello\n\n번역") + expect(events.some((e) => e.type === "finish")).toBe(true) +}) +test("real Gemini parser accepts the translated final text part", async () => { + const events = await parse(Gemini.protocol, "gemini", [ + { candidates: [{ index: 0, content: { role: "model", parts: [{ text: "Hello" }] } }] }, + { + candidates: [{ index: 0, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2, totalTokenCount: 3 }, + }, + ]) + expect( + events + .filter((e) => e.type === "text-delta") + .map((e) => e.text) + .join(""), + ).toBe("Hello\n\n번역") + expect(events.some((e) => e.type === "finish")).toBe(true) +}) diff --git a/test/question-tool/helpers.test.ts b/test/question-tool/helpers.test.ts index 3a59bb5..17c5f72 100644 --- a/test/question-tool/helpers.test.ts +++ b/test/question-tool/helpers.test.ts @@ -57,6 +57,21 @@ describe("question-tool helpers", () => { expect(calls).toBe(1) expect(args).toEqual(cloneSampleArgs()) }) + test("invalid batch sizes do not partially overwrite questions or custom answers", async () => { + const args = cloneSampleArgs() + await expect(translateQuestionArgs(args, async () => [])).rejects.toThrow("translations for") + expect(args).toEqual(cloneSampleArgs()) + const original = snapshotQuestions(args) + const errors: unknown[] = [] + const output = await buildRestoredOutput(original, original, [["Custom"]], { + translateCustomAnswers: async () => [], + onTranslationError: async (error) => { + errors.push(error) + }, + }) + expect(output).toContain("Custom") + expect(errors).toHaveLength(1) + }) test("buildRestoredOutput reconstructs the English output from a Korean answer", async () => { const original = snapshotQuestions(sampleArgs) diff --git a/test/question-tool/helpers.ts b/test/question-tool/helpers.ts index aba31dd..6ec2ca9 100644 --- a/test/question-tool/helpers.ts +++ b/test/question-tool/helpers.ts @@ -1,45 +1,5 @@ -import type { PluginClientLike, TranslateState } from "../../src/constants" import type { QuestionArgs } from "../../src/question-tool" -export function fakeClient(state: Partial = {}): PluginClientLike { - const translateState = { - translate_enabled: true, - translate_user_lang: "Korean", - translate_llm_lang: "English", - translate_nonce: "a".repeat(32), - ...state, - } - - return { - session: { - get: async () => ({ data: { id: "ses_1", parentID: null } }), - messages: async () => ({ - data: [ - { - info: { id: "msg_banner", sessionID: "ses_1", role: "user" }, - parts: [ - { - id: "banner", - sessionID: "ses_1", - messageID: "msg_banner", - type: "text", - text: "banner", - synthetic: false, - ignored: true, - metadata: { ...translateState, translate_role: "activation_banner" }, - }, - ], - }, - ], - }), - message: async () => ({ data: { info: { id: "msg_1", sessionID: "ses_1", role: "assistant" }, parts: [] } }), - }, - provider: { list: async () => ({ data: { all: [] } }) }, - auth: { set: async () => ({ data: {} }) }, - app: { log: async () => ({ data: {} }) }, - } as unknown as PluginClientLike -} - export const sampleArgs: QuestionArgs = { questions: [ { diff --git a/test/question-tool/hooks.test.ts b/test/question-tool/hooks.test.ts deleted file mode 100644 index 9fdf0ad..0000000 --- a/test/question-tool/hooks.test.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { beforeEach, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import { cloneSampleArgs, fakeClient } from "./helpers" - -beforeEach(() => { - __resetActivationCacheForTest() -}) - -test("tool.execute.before translates question args when state is active", async () => { - const batches: string[][] = [] - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => `[ko]${text}`, - translateTexts: async ({ texts }) => { - batches.push([...texts]) - return texts.map((text) => `[ko]${text}`) - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_1" }, { args } as never) - expect(args.questions[0].question).toBe("[ko]Are you sure?") - expect(args.questions[0].options[0].label).toBe("[ko]Yes, delete") - expect(batches).toEqual([ - ["Are you sure?", "Confirm", "Yes, delete", "This cannot be undone.", "No, cancel", "Keep the file."], - ]) -}) - -test("tool.execute.before removes echoed text envelope from translated question args", async () => { - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => `\n[ko]${text}\n`, - translateTexts: async ({ texts }) => texts.map((text) => `\n[ko]${text}\n`), - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_wrapped" }, { - args, - } as never) - expect(args.questions[0].question).toBe("[ko]Are you sure?") - expect(args.questions[0].options[0].label).toBe("[ko]Yes, delete") - expect(args.questions[0].question).not.toContain("") -}) - -test("tool.execute.before no-ops for non-question tools and English language", async () => { - const nonQuestionHooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async () => { - throw new Error("should not be called") - }, - }, - }, - ) - const nonQuestionArgs = { command: "ls -la" } - await nonQuestionHooks["tool.execute.before"]!({ tool: "bash", sessionID: "ses_1", callID: "call_2" }, { - args: nonQuestionArgs, - } as never) - expect(nonQuestionArgs).toEqual({ command: "ls -la" }) - - const englishHooks = createHooks( - { client: fakeClient({ translate_user_lang: "English" }), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "English" }, - { - translator: { - translateText: async () => { - throw new Error("should not be called") - }, - }, - }, - ) - const englishArgs = cloneSampleArgs() - await englishHooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_3" }, { - args: englishArgs, - } as never) - expect(englishArgs.questions[0].question).toBe("Are you sure?") -}) - -test("tool.execute.after restores the English output string", async () => { - const calls: string[] = [] - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(`${direction}:${text}`) - return `[ko]${text}` - }, - translateTexts: async ({ texts, direction }) => { - calls.push(`${direction}:${texts.join("|")}`) - return texts.map((text) => `[ko]${text}`) - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_4" }, { args } as never) - const afterOutput = { - title: "Asked 1 question", - output: `User has answered your questions: "[ko]Are you sure?"="[ko]Yes, delete". You can now continue with the user's answers in mind.`, - metadata: { answers: [["[ko]Yes, delete"]] }, - } - - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_4", args }, - afterOutput as never, - ) - expect(afterOutput.output).toBe( - 'User has answered your questions: "Are you sure?"="Yes, delete". You can now continue with the user\'s answers in mind.', - ) - expect(afterOutput.metadata.answers).toEqual([["Yes, delete"]]) - expect(args.questions[0].question).toBe("Are you sure?") - expect(args.questions[0].options[0].label).toBe("Yes, delete") - expect(calls).toEqual(["outbound:Are you sure?|Confirm|Yes, delete|This cannot be undone.|No, cancel|Keep the file."]) - expect(calls).not.toContain("inbound:[ko]Yes, delete") -}) - -test("tool.execute.after translates free-text custom answers", async () => { - const calls: string[] = [] - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(`${direction}:${text}`) - return direction === "outbound" ? `[ko]${text}` : `EN:${text}` - }, - translateTexts: async ({ texts, direction }) => { - calls.push(`${direction}:${texts.join("|")}`) - return texts.map((text) => (direction === "outbound" ? `[ko]${text}` : `EN:${text}`)) - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_custom" }, { - args, - } as never) - const afterOutput = { - title: "Asked 1 question", - output: `User has answered your questions: "[ko]Are you sure?"="직접 입력한 답변". You can now continue with the user's answers in mind.`, - metadata: { answers: [["직접 입력한 답변"]] }, - } - - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_custom", args }, - afterOutput as never, - ) - expect(afterOutput.output).toContain('"Are you sure?"="EN:직접 입력한 답변"') - expect(afterOutput.metadata.answers).toEqual([["EN:직접 입력한 답변"]]) - expect(args.questions[0].question).toBe("Are you sure?") - expect(calls).toContain("inbound:직접 입력한 답변") -}) - -test("tool.execute.after falls back to translateText for custom answers", async () => { - const calls: string[] = [] - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(`${direction}:${text}`) - return direction === "outbound" ? `[ko]${text}` : `EN:${text}` - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_custom_fallback" }, { - args, - } as never) - const afterOutput = { - title: "Asked 1 question", - output: `User has answered your questions: "[ko]Are you sure?"="직접 입력한 답변". You can now continue with the user's answers in mind.`, - metadata: { answers: [["직접 입력한 답변"]] }, - } - - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_custom_fallback", args }, - afterOutput as never, - ) - - expect(afterOutput.output).toContain('"Are you sure?"="EN:직접 입력한 답변"') - expect(afterOutput.metadata.answers).toEqual([["EN:직접 입력한 답변"]]) - expect(calls).toContain("inbound:직접 입력한 답변") -}) - -test("tool.execute.after does not translate custom answers when language is English", async () => { - const calls: string[] = [] - const hooks = createHooks( - { client: fakeClient({ translate_user_lang: "English" }), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "English" }, - { - translator: { - translateText: async ({ text, direction }) => { - calls.push(`${direction}:${text}`) - return `translated:${text}` - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_display_en" }, { - args, - } as never) - const afterOutput = { - title: "Asked 1 question", - output: `User has answered your questions: "Are you sure?"="custom answer". You can now continue with the user's answers in mind.`, - metadata: { answers: [["custom answer"]] }, - } - - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_display_en", args }, - afterOutput as never, - ) - expect(afterOutput.output).toContain('"Are you sure?"="custom answer"') - expect(afterOutput.metadata.answers).toEqual([["custom answer"]]) - expect(args.questions[0].question).toBe("Are you sure?") - expect(calls).toEqual([]) -}) - -test("tool.execute.after swallows before-hook translation errors and leaves args English", async () => { - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async () => { - throw new Error("translator down") - }, - translateTexts: async () => { - throw new Error("translator down") - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_5" }, { args } as never) - expect(args.questions[0].question).toBe("Are you sure?") - expect(args.questions[0].options[0].label).toBe("Yes, delete") - - const afterOutput = { title: "x", output: "unchanged", metadata: { answers: [] } } - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_5", args }, - afterOutput as never, - ) - expect(afterOutput.output).toBe("unchanged") -}) - -test("tool.execute.before logs state lookup failures without throwing", async () => { - const logs: string[] = [] - const client = { - ...fakeClient(), - session: { - get: async () => { - throw new Error("state unavailable") - }, - messages: async () => ({ data: [] }), - message: async () => ({ data: { info: { id: "msg_1", sessionID: "ses_1", role: "assistant" }, parts: [] } }), - }, - app: { - log: async (input: { body: { message: string } }) => { - logs.push(input.body.message) - return { data: {} } - }, - }, - } - const hooks = createHooks( - { client, directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { translator: { translateText: async ({ text }) => `[ko]${text}` } }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_lookup_fail" }, { - args, - } as never) - - expect(args.questions[0].question).toBe("Are you sure?") - expect(logs).toEqual(["state unavailable"]) -}) - -test("tool.execute.after logs custom answer translation failures with source-language context", async () => { - const logs: string[] = [] - const hooks = createHooks( - { - client: { - ...fakeClient(), - app: { - log: async (input: { body: { message: string } }) => { - logs.push(input.body.message) - return { data: {} } - }, - }, - }, - directory: "/workspace", - } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text, direction }) => { - if (direction === "outbound") return `[ko]${text}` - throw new Error("custom answer translator down") - }, - translateTexts: async ({ texts, direction }) => { - if (direction === "outbound") return texts.map((text) => `[ko]${text}`) - throw new Error("custom answer translator down") - }, - }, - }, - ) - - const args = cloneSampleArgs() - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: "call_custom_fail" }, { - args, - } as never) - const afterOutput = { - title: "Asked 1 question", - output: `User has answered your questions: "[ko]Are you sure?"="직접 입력". You can now continue with the user's answers in mind.`, - metadata: { answers: [["직접 입력"]] }, - } - - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_custom_fail", args }, - afterOutput as never, - ) - - expect(afterOutput.output).toContain('"Are you sure?"="직접 입력"') - expect(logs.at(-1)).toContain( - "Failed to translate user message from Korean to English: custom answer translator down", - ) -}) - -test("question snapshots are capped to avoid unbounded retention", async () => { - const hooks = createHooks( - { client: fakeClient(), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { translator: { translateText: async ({ text }) => `[ko]${text}` } }, - ) - - for (let index = 0; index < 1001; index += 1) { - await hooks["tool.execute.before"]!({ tool: "question", sessionID: "ses_1", callID: `call_${index}` }, { - args: cloneSampleArgs(), - } as never) - } - - const prunedOutput = { - output: "unchanged", - metadata: { answers: [["[ko]Yes, delete"]] }, - } - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_0", args: cloneSampleArgs() } as never, - prunedOutput as never, - ) - expect(prunedOutput.output).toBe("unchanged") - - const retainedOutput = { - output: "translated", - metadata: { answers: [["[ko]Yes, delete"]] }, - } - await hooks["tool.execute.after"]!( - { tool: "question", sessionID: "ses_1", callID: "call_1000", args: cloneSampleArgs() } as never, - retainedOutput as never, - ) - expect(retainedOutput.output).toContain('"Are you sure?"="Yes, delete"') -}) diff --git a/test/questions.test.ts b/test/questions.test.ts new file mode 100644 index 0000000..fc112be --- /dev/null +++ b/test/questions.test.ts @@ -0,0 +1,80 @@ +import { expect, spyOn, test } from "bun:test" +import { setup } from "../src/activation" +import { host, requestContext } from "./helpers" +import { cloneSampleArgs } from "./question-tool/helpers" + +test("v2 question forms translate, restore labels and custom answers, and update structured output", async () => { + const h = host() + h.values.set("sessions/ses_1", "Korean") + h.generate(async (input) => + input + .match(/[\s\S]*?<\/segment>/g)! + .map((text) => text.replace(/(\n)([\s\S]*?)(\n<\/segment>)/, "$1translated:$2$3")) + .join("\n"), + ) + await setup(h.ctx) + const event = { tool: "question", sessionID: "ses_1", id: "call_1", input: cloneSampleArgs() } + await h.emit("tool.execute.before", event) + expect(event.input.questions[0].question).toBe("translated:Are you sure?") + const after = { + ...event, + status: "completed", + result: { + content: [{ type: "text", text: "native question result" }], + output: { answers: [["translated:Yes, delete", "사용자 답변"]] }, + metadata: { answers: [["translated:Yes, delete", "사용자 답변"]], keep: true }, + }, + } + await h.emit("tool.execute.after", after) + expect(after.input.questions[0].question).toBe("translated:Are you sure?") + const context = requestContext([ + { role: "assistant", content: [{ type: "tool-call", name: "question", id: "call_1", input: after.input }] }, + ]) + await h.emit("session.context", context) + expect(context.messages).toMatchObject([{ content: [{ input: cloneSampleArgs() }] }]) + expect(after.result.content as unknown).toBe( + 'User has answered your questions: "Are you sure?"="Yes, delete, translated:사용자 답변". You can now continue with the user\'s answers in mind.', + ) + expect(after.result.output.answers).toEqual([["Yes, delete", "translated:사용자 답변"]]) + expect(after.result.metadata.keep).toBe(true) +}) +test("failed question calls release snapshots, with call IDs isolated by session", async () => { + const h = host({ lang: "English" }) + h.values.set("sessions/ses_1", "English") + await setup(h.ctx) + const event = { tool: "question", sessionID: "ses_1", id: "call_1", input: cloneSampleArgs() } + await h.emit("tool.execute.before", event) + await h.emit("tool.execute.after", { ...event, status: "error", error: { message: "cancelled" } }) + const after = { ...event, status: "completed", result: { content: "untouched", metadata: { answers: [] } } } + await h.emit("tool.execute.after", after) + expect(after.result.content).toBe("untouched") +}) +test("question translation errors preserve the form and custom answer errors preserve the answer", async () => { + const h = host() + h.values.set("sessions/ses_1", "Korean") + h.generate(async () => { + throw new Error("translator offline") + }) + await setup(h.ctx) + const event = { tool: "question", sessionID: "ses_1", id: "call_1", input: cloneSampleArgs() } + const log = spyOn(console, "error").mockImplementation(() => {}) + try { + await h.emit("tool.execute.before", event) + expect(event.input).toEqual(cloneSampleArgs()) + h.generate(async (input) => input.match(/[\s\S]*?<\/segment>/g)!.join("\n")) + await h.emit("tool.execute.before", event) + h.generate(async () => { + throw new Error("translator offline") + }) + const after = { + ...event, + status: "completed", + result: { content: "original", metadata: { answers: [["사용자 답변"]] } }, + } + await h.emit("tool.execute.after", after) + expect(after.result.content).toContain("사용자 답변") + expect(log).toHaveBeenCalledTimes(2) + } finally { + log.mockRestore() + } +}) diff --git a/test/response.test.ts b/test/response.test.ts new file mode 100644 index 0000000..1d8ad91 --- /dev/null +++ b/test/response.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, test } from "bun:test" +import { setup } from "../src/activation" +import { composeTranslatedAssistantText } from "../src/formatting" +import { getDisplayLanguageLabel } from "../src/labels" +import { type ResponseTranslation, translateResponse } from "../src/response" +import { host, prompt, requestContext } from "./helpers" + +function sse(events: unknown[], newline = "\n") { + return events + .map((event) => `data: ${typeof event === "string" ? event : JSON.stringify(event)}${newline}${newline}`) + .join("") +} +function source(text: string, split = 1) { + const bytes = new TextEncoder().encode(text) + let index = 0 + return new Response( + new ReadableStream({ + pull(controller) { + if (index >= bytes.length) { + controller.close() + return + } + controller.enqueue(bytes.slice(index, index + split)) + index += split + }, + }), + { + headers: { + "content-type": "text/event-stream", + "x-original": "preserved", + "content-length": String(bytes.length), + }, + }, + ) +} +function output(text: string) { + return text.split(/\n\n/).flatMap((frame) => { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join("\n") + return !data || data === "[DONE]" ? [] : [JSON.parse(data)] + }) +} +function translation(overrides: Partial = {}) { + const calls: string[] = [] + const records = new Map() + const warnings: string[] = [] + const options: ResponseTranslation = { + lang: "Korean", + signal: new AbortController().signal, + translate: async (text) => { + calls.push(text) + return `번역:${text}` + }, + remember: async (display, english) => { + records.set(display, english) + }, + warn: (message) => { + warnings.push(message) + }, + ...overrides, + } + return { options, calls, records, warnings } +} +const bilingual = (english: string) => + composeTranslatedAssistantText(english, getDisplayLanguageLabel("Korean"), `번역:${english}`) + +describe("native response adapters", () => { + test("Responses keeps deltas and every final snapshot consistent without duplicate translations", async () => { + const t = translation() + const item = { type: "message", id: "item_1", content: [{ type: "output_text", text: "Hello" }] } + const wire = sse( + [ + { type: "response.output_item.added", output_index: 0, item: { ...item, content: [] } }, + { type: "response.output_text.delta", item_id: "item_1", output_index: 0, content_index: 0, delta: "Hel" }, + { type: "response.output_text.delta", item_id: "item_1", output_index: 0, content_index: 0, delta: "lo" }, + { type: "response.output_text.done", item_id: "item_1", output_index: 0, content_index: 0, text: "Hello" }, + { + type: "response.content_part.done", + item_id: "item_1", + output_index: 0, + content_index: 0, + part: item.content[0], + }, + { type: "response.output_item.done", output_index: 0, item }, + { type: "response.completed", response: { id: "resp_1", output: [item], usage: { output_tokens: 3 } } }, + "[DONE]", + ], + "\r\n", + ) + const response = translateResponse( + new Request("https://chatgpt.com/backend-api/codex/responses"), + source(wire), + t.options, + ) + const events = output(await response.text()) + expect( + events + .filter((e) => e.type === "response.output_text.delta") + .map((e) => e.delta) + .join(""), + ).toBe(bilingual("Hello")) + expect(events.find((e) => e.type === "response.output_text.done").text).toBe(bilingual("Hello")) + expect(events.find((e) => e.type === "response.content_part.done").part.text).toBe(bilingual("Hello")) + expect(events.find((e) => e.type === "response.output_item.done").item.content[0].text).toBe(bilingual("Hello")) + expect(events.at(-1).response.output[0].content[0].text).toBe(bilingual("Hello")) + expect(events.at(-1).response.usage).toEqual({ output_tokens: 3 }) + expect(t.calls).toEqual(["Hello"]) + expect(t.records.get(bilingual("Hello"))).toBe("Hello") + expect(response.headers.get("x-original")).toBe("preserved") + expect(response.headers.has("content-length")).toBe(false) + }) + test("Responses handles snapshot-only text and leaves reasoning and function arguments intact", async () => { + const t = translation() + const reasoning = { + type: "reasoning", + id: "reason_1", + encrypted_content: "opaque", + summary: [{ type: "summary_text", text: "reason" }], + } + const tool = { type: "function_call", id: "tool_1", arguments: '{"text":"Hello"}' } + const text = { type: "message", id: "item_1", content: [{ type: "output_text", text: "Hello" }] } + const response = translateResponse( + new Request("https://api.example/v1/responses"), + source(sse([{ type: "response.completed", response: { output: [reasoning, tool, text] } }])), + t.options, + ) + const events = output(await response.text()) + expect(events.at(-1).response.output.slice(0, 2)).toEqual([reasoning, tool]) + expect(events.at(-1).response.output[2].content[0].text).toBe(bilingual("Hello")) + expect(t.calls).toEqual(["Hello"]) + }) + test("Anthropic appends before block stop, preserving signatures and tool input", async () => { + const t = translation() + const untouched = [ + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "", signature: "signed" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Reason" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 2, + content_block: { type: "tool_use", id: "tool_1", name: "read", input: {} }, + }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: '{"path":"file"}' } }, + { type: "content_block_stop", index: 2 }, + ] + const response = translateResponse( + new Request("https://api.anthropic.com/v1/messages"), + source( + sse([ + ...untouched, + { type: "content_block_start", index: 3, content_block: { type: "text", text: "Hi " } }, + { type: "content_block_delta", index: 3, delta: { type: "text_delta", text: "世界" } }, + { type: "content_block_stop", index: 3 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 12 } }, + { type: "message_stop" }, + ]), + ), + t.options, + ) + const events = output(await response.text()) + expect(events.slice(0, untouched.length)).toEqual(untouched) + const block = events.filter((e) => e.index === 3) + expect( + block[0].content_block.text + + block + .filter((e) => e.type === "content_block_delta") + .map((e) => e.delta.text) + .join(""), + ).toBe(bilingual("Hi 世界")) + expect(block.at(-1).type).toBe("content_block_stop") + expect(t.calls).toEqual(["Hi 世界"]) + }) + test("Chat handles parallel choices, final deltas, tool calls and usage-only frames", async () => { + const t = translation() + const tool = { + index: 1, + delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] }, + finish_reason: "tool_calls", + } + const response = translateResponse( + new Request("https://azure.example/openai/deployments/model/chat/completions?api-version=1"), + source( + sse([ + { id: "chat_1", choices: [{ index: 0, delta: { content: "Hel", reasoning_content: "Reason" } }] }, + { id: "chat_1", choices: [{ index: 0, delta: { content: "lo" }, finish_reason: "stop" }, tool] }, + { choices: [], usage: { completion_tokens: 2 } }, + "[DONE]", + ]), + ), + t.options, + ) + const events = output(await response.text()) + expect(events[0].choices[0].delta.reasoning_content).toBe("Reason") + expect(events[0].choices[0].delta.content + events[1].choices[0].delta.content).toBe(bilingual("Hello")) + expect(events[1].choices[1]).toEqual(tool) + expect(events[1].choices[0].finish_reason).toBe("stop") + expect(events[2].usage.completion_tokens).toBe(2) + }) + test("Gemini preserves thought signatures, media and function calls", async () => { + const t = translation() + const thought = { text: "Thinking", thought: true, thoughtSignature: "signed" } + const tool = { functionCall: { name: "read", args: {} }, thoughtSignature: "tool-signature" } + const response = translateResponse( + new Request("https://generativelanguage.googleapis.com/v1beta/models/gemini:streamGenerateContent?alt=sse"), + source( + sse([ + { candidates: [{ index: 0, content: { role: "model", parts: [thought, { text: "Hello" }] } }] }, + { + candidates: [{ index: 0, content: { role: "model", parts: [tool] }, finishReason: "STOP" }], + usageMetadata: { candidatesTokenCount: 10 }, + }, + ]), + ), + t.options, + ) + const events = output(await response.text()) + expect(events[0].candidates[0].content.parts[0]).toEqual(thought) + expect(events[1].candidates[0].content.parts[0]).toEqual(tool) + expect(`Hello${events[1].candidates[0].content.parts[1].text}`).toBe(bilingual("Hello")) + expect(events[1].usageMetadata.candidatesTokenCount).toBe(10) + }) + test("translation failure preserves English and records the failure trailer for filtering", async () => { + const t = translation({ + translate: async () => { + throw new Error("unavailable") + }, + }) + const response = translateResponse( + new Request("https://api.example/v1/chat/completions"), + source(sse([{ choices: [{ index: 0, delta: { content: "Hello" }, finish_reason: "stop" }] }])), + t.options, + ) + const text = output(await response.text())[0].choices[0].delta.content + expect(text).toStartWith("Hello\n\n---") + expect(text).toContain("Translation unavailable") + expect(t.records.get(text)).toBe("Hello") + }) + test("storage failure does not emit an untracked translation", async () => { + const t = translation({ + remember: async () => { + throw new Error("disk full") + }, + }) + const response = translateResponse( + new Request("https://api.example/v1/chat/completions"), + source(sse([{ choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] }])), + t.options, + ) + expect(output(await response.text())[0].choices[0].delta.content).toBe("Hello") + expect(t.warnings.join()).toContain("disk full") + }) + test("unknown and non-SSE protocols pass through without consuming their body", async () => { + const t = translation() + for (const [url, type] of [ + ["https://bedrock.example/model/model/converse-stream", "application/vnd.amazon.eventstream"], + ["https://api.example/v1/responses", "application/json"], + ]) { + const original = new Response("original", { headers: { "content-type": type } }) + expect(translateResponse(new Request(url), original, t.options)).toBe(original) + expect(original.bodyUsed).toBe(false) + } + expect(t.calls).toHaveLength(0) + }) + test("incomplete streams are not manufactured into successful completions", async () => { + const t = translation() + const wire = `${sse([{ choices: [{ delta: { content: "partial" } }] }])}data: {"unfinished"` + const response = translateResponse(new Request("https://api.example/v1/chat/completions"), source(wire), t.options) + expect(await response.text()).toBe(wire) + expect(t.calls).toHaveLength(0) + }) + test("English streams before translation is requested", async () => { + const t = translation() + const wire = sse([ + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]) + const response = translateResponse(new Request("https://api.example/v1/chat/completions"), source(wire), t.options) + const reader = response.body!.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toContain("Hello") + expect(t.calls).toHaveLength(0) + await reader.cancel() + }) + test("request cancellation closes the upstream stream", async () => { + let cancelled = false + const controller = new AbortController() + const original = new Response( + new ReadableStream({ + cancel() { + cancelled = true + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + const response = translateResponse( + new Request("https://api.example/v1/responses", { signal: controller.signal }), + original, + translation().options, + ) + const pending = response.text() + controller.abort(new Error("stopped")) + await expect(pending).rejects.toThrow("stopped") + expect(cancelled).toBe(true) + }) + test("upstream cancellation errors do not replace the caller's abort reason", async () => { + const controller = new AbortController() + const original = new Response( + new ReadableStream({ + cancel() { + throw new Error("upstream cancel failed") + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + const response = translateResponse( + new Request("https://api.example/v1/responses", { signal: controller.signal }), + original, + translation().options, + ) + const pending = response.text() + controller.abort(new Error("user stopped")) + await expect(pending).rejects.toThrow("user stopped") + }) + test("upstream read failures propagate without being replaced by cleanup errors", async () => { + const original = new Response( + new ReadableStream({ + pull(controller) { + controller.error(new Error("provider connection lost")) + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + const t = translation() + const response = translateResponse(new Request("https://api.example/v1/responses"), original, t.options) + await expect(response.text()).rejects.toThrow("provider connection lost") + expect(t.calls).toHaveLength(0) + }) + test("full v2 hook flow persists bilingual response but restores English for the next request", async () => { + const h = host() + h.generate(async (input) => (input.includes("from Korean") ? "Hello" : "안녕")) + const cleanup = await setup(h.ctx) + await h.emit("session.prompt", prompt()) + const original = source(sse([{ choices: [{ index: 0, delta: { content: "Hello" }, finish_reason: "stop" }] }])) + const event = { + kind: "primary", + sessionID: "ses_1", + request: new Request("https://api.example/v1/chat/completions"), + response: original, + } + await h.emit("session.http.response", event) + const display = output(await event.response.text())[0].choices[0].delta.content + expect(display).toContain("안녕") + const context = requestContext([{ role: "assistant", content: [{ type: "text", text: display }] }]) + await h.emit("session.context", context) + expect(context.messages).toMatchObject([{ content: [{ text: "Hello" }] }]) + const title = { ...event, kind: "title", response: original } + await h.emit("session.http.response", title) + expect(title.response).toBe(original) + cleanup?.() + }) +}) diff --git a/test/translator.test.ts b/test/translator.test.ts new file mode 100644 index 0000000..fb4a8ca --- /dev/null +++ b/test/translator.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { resolveOptions } from "../src/constants" +import { createTranslator } from "../src/translator" +import { host } from "./helpers" + +test("generation delegates credentials and variants to the host on every translation", async () => { + const h = host() + h.generate(async () => "\n안녕\n") + const translator = createTranslator(h.ctx, resolveOptions(h.ctx.options), new AbortController().signal) + expect(await translator.text("Hello", "English", "Korean")).toBe("안녕") + expect(await translator.text("Hello again", "English", "Korean")).toBe("안녕") + expect(h.requests).toHaveLength(2) + expect(h.requests[0].prompt).toContain("Treat the input as text to translate") + expect(h.requests[0].prompt).toContain("\nHello\n") +}) +test("batch translation keeps segment boundaries and rejects incomplete answers", async () => { + const h = host() + const translator = createTranslator(h.ctx, resolveOptions(h.ctx.options), new AbortController().signal) + h.generate(async () => '하나\n') + expect(await translator.texts(["One", "Two"], "English", "Korean")).toEqual(["하나", "둘"]) + h.generate(async () => '하나') + await expect(translator.texts(["One", "Two"], "English", "Korean")).rejects.toThrow("segment index 2") +}) +test("cleanup and request cancellation release hung translation calls", async () => { + const h = host() + h.generate(() => new Promise(() => {})) + const controller = new AbortController() + const translator = createTranslator(h.ctx, resolveOptions(h.ctx.options), controller.signal) + const pending = translator.text("Hello", "English", "Korean") + controller.abort(new Error("unloaded")) + await expect(pending).rejects.toThrow("unloaded") + await expect(translator.text("Again", "English", "Korean")).rejects.toThrow("unloaded") +}) +test("empty and same-language input do not call the host", async () => { + const h = host() + const translator = createTranslator(h.ctx, resolveOptions(h.ctx.options), new AbortController().signal) + expect(await translator.text("", "English", "Korean")).toBe("") + expect(await translator.text("Hello", "English", "English")).toBe("Hello") + expect(h.requests).toHaveLength(0) +}) diff --git a/test/translator/create-translator.test.ts b/test/translator/create-translator.test.ts deleted file mode 100644 index caaeb7a..0000000 --- a/test/translator/create-translator.test.ts +++ /dev/null @@ -1,572 +0,0 @@ -import { beforeEach, expect, test } from "bun:test" -import { __resetAuthCachesForTest, createCredentialResolver } from "../../src/auth" -import { resolveOptions } from "../../src/constants" -import { __resetTranslatorCachesForTest, createTranslator } from "../../src/translator" -import { fakeClient } from "./helpers" - -function testOptions(overrides: Record = {}) { - return { - model: "anthropic/claude-haiku-4-5", - trigger: ["$en"], - lang: "Korean", - verbose: false, - ...overrides, - } -} - -beforeEach(() => { - __resetAuthCachesForTest() - __resetTranslatorCachesForTest() -}) - -test("retry succeeds after one transient failure", async () => { - let calls = 0 - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - calls += 1 - if (calls === 1) { - const error = new Error("HTTP 500") as Error & { status?: number } - error.status = 500 - throw error - } - return { text: "hello" } as never - }, - sleep: async () => undefined, - now: (() => { - let value = 0 - return () => (value += 10) - })(), - }) - - const translated = await translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }) - expect(translated).toBe("hello") - expect(calls).toBe(2) -}) - -test("retry can use the default zero-delay sleeper", async () => { - let calls = 0 - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - calls += 1 - if (calls === 1) { - const error = new Error("rate limited") as Error & { status?: number; response?: Response } - error.status = 429 - error.response = new Response("", { status: 429, headers: { "retry-after": "0" } }) - throw error - } - return { text: "hello" } as never - }, - }) - - await expect( - translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }), - ).resolves.toBe("hello") - expect(calls).toBe(2) -}) - -test("unwraps echoed text envelope from translator output", async () => { - const translator = createTranslator(fakeClient([]), testOptions({ lang: "English" }), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => ({ text: "\n안녕하세요\n" }) as never, - sleep: async () => undefined, - }) - - const translated = await translator.translateText({ - text: "Hello", - sourceLanguage: "English", - targetLanguage: "Korean", - direction: "outbound", - }) - expect(translated).toBe("안녕하세요") -}) - -test("translateTexts batches multiple segments into one request", async () => { - let calls = 0 - let request: Record | undefined - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async (input) => { - calls += 1 - request = input as Record - return { - text: '\n안녕하세요\n\n\n계속\n', - } as never - }, - sleep: async () => undefined, - }) - - const translated = await translator.translateTexts({ - texts: ["Hello", "Continue"], - sourceLanguage: "English", - targetLanguage: "Korean", - direction: "outbound", - }) - expect(translated).toEqual(["안녕하세요", "계속"]) - expect(calls).toBe(1) - expect(request?.system).toContain("multiple independent") - expect(request?.prompt).toContain('\nHello\n') -}) - -test("translateTexts skips same-language batches", async () => { - let calls = 0 - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - calls += 1 - return { text: "unused" } as never - }, - sleep: async () => undefined, - }) - - await expect( - translator.translateTexts({ - texts: ["hello", ""], - sourceLanguage: "English", - targetLanguage: "English", - direction: "outbound", - }), - ).resolves.toEqual(["hello", ""]) - expect(calls).toBe(0) -}) - -test("translateTexts treats malformed batch output as one failed request", async () => { - let calls = 0 - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - calls += 1 - return { text: '\n안녕하세요\n' } as never - }, - sleep: async () => undefined, - }) - - await expect( - translator.translateTexts({ - texts: ["Hello", "Continue"], - sourceLanguage: "English", - targetLanguage: "Korean", - direction: "outbound", - }), - ).rejects.toThrow("segment index 2") - expect(calls).toBe(1) -}) - -test("OpenAI reasoning translators omit unsupported temperature", async () => { - let request: Record | undefined - const translator = createTranslator(fakeClient([]), testOptions({ model: "openai/gpt-5.5" }), { - credentialResolver: { - resolve: async () => ({ providerID: "openai", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "OPENAI_API_KEY", - }, - generateTextImpl: async (input) => { - request = input as Record - return { text: "hello" } as never - }, - sleep: async () => undefined, - }) - - await translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }) - expect(request?.temperature).toBeUndefined() - expect(request?.providerOptions).toBeUndefined() -}) - -test("translator passes configured OpenAI variant as providerOptions", async () => { - let request: Record | undefined - const translator = createTranslator(fakeClient([]), testOptions({ model: "openai/gpt-5.5", variant: "minimal" }), { - credentialResolver: { - resolve: async () => ({ - providerID: "openai", - provider: { - id: "openai", - source: "env" as const, - env: ["OPENAI_API_KEY"], - models: { - "gpt-5.5": { - id: "gpt-5.5", - api: { id: "gpt-5.5", npm: "@ai-sdk/openai" }, - variants: { minimal: { reasoningEffort: "minimal" } }, - }, - }, - }, - apiKey: "test-key", - mode: "apiKey" as const, - }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "OPENAI_API_KEY", - }, - generateTextImpl: async (input) => { - request = input as Record - return { text: "hello" } as never - }, - sleep: async () => undefined, - }) - - await translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }) - expect(request?.providerOptions).toEqual({ openai: { reasoningEffort: "minimal" } }) -}) - -test("translator passes configured Anthropic variant as providerOptions", async () => { - let request: Record | undefined - const translator = createTranslator( - fakeClient([]), - testOptions({ model: "anthropic/claude-opus-4-8", variant: "max" }), - { - credentialResolver: { - resolve: async () => ({ - providerID: "anthropic", - provider: { - id: "anthropic", - source: "env" as const, - env: ["ANTHROPIC_API_KEY"], - models: { - "claude-opus-4-8": { - id: "claude-opus-4-8", - api: { id: "claude-opus-4-8", npm: "@ai-sdk/anthropic" }, - variants: { max: { thinking: { type: "enabled", budgetTokens: 31_999 } } }, - }, - }, - }, - apiKey: "test-key", - mode: "apiKey" as const, - }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async (input) => { - request = input as Record - return { text: "hello" } as never - }, - sleep: async () => undefined, - }, - ) - - await translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }) - expect(request?.providerOptions).toEqual({ - anthropic: { thinking: { type: "enabled", budgetTokens: 31_999 } }, - }) -}) - -test("unknown variants fail before generateText", async () => { - let calls = 0 - const translator = createTranslator(fakeClient([]), testOptions({ variant: "max" }), { - credentialResolver: { - resolve: async () => ({ - providerID: "anthropic", - provider: { - id: "anthropic", - source: "env" as const, - env: ["ANTHROPIC_API_KEY"], - models: { - "claude-haiku-4-5": { - id: "claude-haiku-4-5", - api: { id: "claude-haiku-4-5", npm: "@ai-sdk/anthropic" }, - variants: { high: { thinking: { type: "enabled", budgetTokens: 16_000 } } }, - }, - }, - }, - apiKey: "test-key", - mode: "apiKey" as const, - }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - calls += 1 - return { text: "hello" } as never - }, - sleep: async () => undefined, - }) - - await expect( - translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }), - ).rejects.toThrow( - '[opencode-translate:INVALID_VARIANT] options.variant "max" is not available for "anthropic/claude-haiku-4-5". Available variants: high.', - ) - expect(calls).toBe(0) -}) - -test("missing credentials surface the exact auth-unavailable error", async () => { - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ - providerID: "anthropic", - provider: { id: "anthropic", source: "env", env: ["ANTHROPIC_API_KEY"] }, - mode: "default" as const, - }), - isMissingCredentialError: () => true, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => { - throw new Error("Missing API key") - }, - sleep: async () => undefined, - }) - - await expect( - translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }), - ).rejects.toThrow( - '[opencode-translate:AUTH_UNAVAILABLE] No credential found for provider "anthropic". Set ANTHROPIC_API_KEY in the environment or run "opencode auth login anthropic".', - ) -}) - -test("OpenAI OAuth translator requests are Codex-compatible", async () => { - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - openai: { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: Date.now() + 3600_000, - accountId: "acct_1", - }, - }) - const client = { - ...fakeClient([]), - provider: { - list: async () => ({ - all: [{ id: "openai", source: "custom" as const, env: ["OPENAI_API_KEY"], key: "opencode-oauth-dummy-key" }], - }), - }, - } - const options = resolveOptions({ model: "openai/gpt-5.5", lang: "Korean" }) - let finalUrl = "" - let finalBody = "" - const credentialResolver = createCredentialResolver(client, { - fetchImpl: async (input, init) => { - finalUrl = input instanceof URL ? input.href : String(input) - finalBody = String(init?.body) - return new Response( - [ - `event: response.output_text.delta\ndata: ${JSON.stringify({ type: "response.output_text.delta", delta: "hello" })}\n\n`, - `event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response: { id: "resp_1", created_at: 1_700_000_000, model: "gpt-5.5", output: [], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } })}\n\n`, - ].join(""), - { status: 200, headers: { "Content-Type": "text/event-stream" } }, - ) - }, - sleep: async () => undefined, - }) - const translator = createTranslator(client, options, { credentialResolver, sleep: async () => undefined }) - - try { - const translated = await translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }) - const parsed = JSON.parse(finalBody) as Record - expect(translated).toBe("hello") - expect(finalUrl).toBe("https://chatgpt.com/backend-api/codex/responses") - expect(parsed.instructions).toContain("professional translator") - expect(parsed.stream).toBe(true) - expect(parsed.include).toEqual(["reasoning.encrypted_content"]) - expect(parsed.input).toEqual([ - { type: "message", role: "user", content: [{ type: "input_text", text: "\n안녕\n" }] }, - ]) - } finally { - delete process.env.OPENCODE_AUTH_CONTENT - } -}) - -test("verbose translation logs timing and character metadata", async () => { - const logs: unknown[] = [] - const client = { - ...fakeClient([]), - app: { - log: async (input: unknown) => { - logs.push(input) - return undefined - }, - }, - } - const translator = createTranslator(client, testOptions({ verbose: true }), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => ({ text: "hello" }) as never, - sleep: async () => undefined, - now: (() => { - const values = [100, 145] - return () => values.shift() ?? 145 - })(), - }) - - await expect( - translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }), - ).resolves.toBe("hello") - expect(logs).toEqual([ - { - body: { - service: "opencode-translate", - level: "info", - message: "translated", - extra: { - direction: "inbound", - chars_in: 2, - chars_out: 5, - ms: 45, - cached: false, - model: "anthropic/claude-haiku-4-5", - }, - }, - }, - ]) -}) - -test("verbose batch translation logs aggregate segment metadata", async () => { - const logs: unknown[] = [] - const client = { - ...fakeClient([]), - app: { - log: async (input: unknown) => { - logs.push(input) - return undefined - }, - }, - } - const translator = createTranslator(client, testOptions({ verbose: true }), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => - ({ text: '\n안녕\n\n\n계속\n' }) as never, - sleep: async () => undefined, - now: (() => { - const values = [100, 145] - return () => values.shift() ?? 145 - })(), - }) - - await expect( - translator.translateTexts({ - texts: ["Hello", "Continue"], - sourceLanguage: "English", - targetLanguage: "Korean", - direction: "outbound", - }), - ).resolves.toEqual(["안녕", "계속"]) - expect(logs).toEqual([ - { - body: { - service: "opencode-translate", - level: "info", - message: "translated", - extra: { - direction: "outbound", - chars_in: 13, - chars_out: 4, - segments: 2, - ms: 45, - cached: false, - model: "anthropic/claude-haiku-4-5", - }, - }, - }, - ]) -}) - -test("translator requests are bounded by the configured timeout", async () => { - const translator = createTranslator(fakeClient([]), testOptions(), { - credentialResolver: { - resolve: async () => ({ providerID: "anthropic", apiKey: "test-key", mode: "apiKey" as const }), - isMissingCredentialError: () => false, - authUnavailable: () => new Error("unused"), - envFallback: "ANTHROPIC_API_KEY", - }, - generateTextImpl: async () => new Promise(() => undefined) as never, - sleep: async () => undefined, - timeoutMs: 1, - }) - - await expect( - translator.translateText({ - text: "안녕", - sourceLanguage: "Korean", - targetLanguage: "English", - direction: "inbound", - }), - ).rejects.toThrow("Translator generateText timed out after 1ms") -}) diff --git a/test/translator/helpers.ts b/test/translator/helpers.ts deleted file mode 100644 index 18345f3..0000000 --- a/test/translator/helpers.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MessageWithPartsLike, PluginClientLike, TextPartLike } from "../../src/constants" -import { hashText } from "../../src/translator" - -export function textPart(id: string, text: string, extra: Partial = {}): TextPartLike { - return { - id, - sessionID: "ses_1", - messageID: "msg_1", - type: "text", - text, - ...extra, - } -} - -export function activeStateMetadata(text: string) { - return { - translate_enabled: true, - translate_user_lang: "Korean", - translate_llm_lang: "English", - translate_nonce: "0123456789abcdef0123456789abcdef", - translate_source_hash: hashText(text), - translate_en: `EN:${text}`, - } -} - -export function fakeClient(messages: MessageWithPartsLike[]): PluginClientLike { - return { - session: { - get: async () => ({ id: "ses_1", parentID: null }), - messages: async () => messages, - message: async () => messages[0], - }, - provider: { - list: async () => ({ all: [] }), - }, - auth: { - set: async () => undefined, - }, - app: { - log: async () => undefined, - }, - } -} - -export function encodeAscendingPartIDForTest(timestamp: number, counter: number): string { - const encoded = BigInt(timestamp) * BigInt(0x1000) + BigInt(counter) - const bytes = Buffer.alloc(6) - for (let index = 0; index < 6; index += 1) { - bytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & BigInt(0xff)) - } - return `prt_${bytes.toString("hex")}00000000000000` -} diff --git a/test/translator/hook-failure.test.ts b/test/translator/hook-failure.test.ts deleted file mode 100644 index 19ffe7e..0000000 --- a/test/translator/hook-failure.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { __resetActivationCacheForTest, createHooks } from "../../src/activation" -import { __resetAuthCachesForTest } from "../../src/auth" -import type { TextPartLike } from "../../src/constants" -import { __resetTranslatorCachesForTest } from "../../src/translator" -import { activeStateMetadata, fakeClient, textPart } from "./helpers" - -describe("translator hook failures", () => { - beforeEach(() => { - __resetActivationCacheForTest() - __resetAuthCachesForTest() - __resetTranslatorCachesForTest() - }) - - test("final failure in chat.message does not throw and falls back to the untranslated text", async () => { - const hooks = createHooks( - { client: fakeClient([]), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async () => { - throw new Error("translator unavailable") - }, - }, - }, - ) - - const output = { message: { id: "msg_new" }, parts: [textPart("p1", "$en 안녕")] } - await hooks["chat.message"]!({ sessionID: "ses_1" }, output as never) - - expect((output.parts[0] as TextPartLike).text).toContain("안녕") - expect((output.parts[0] as TextPartLike).text).toContain("Translation failed") - expect((output.parts[0] as TextPartLike).metadata?.translate_en).toBeUndefined() - }) - - test("transform leaves user parts untouched on the LLM-only twin architecture", async () => { - let calls = 0 - const messages = [ - { - info: { id: "msg_user", sessionID: "ses_1", role: "user" }, - parts: [textPart("hist", "안녕", { metadata: activeStateMetadata("안녕") })], - }, - ] - const hooks = createHooks( - { client: fakeClient(messages), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { - messages: [ - { - info: { id: "msg_user", sessionID: "ses_1", role: "user" }, - parts: [textPart("hist", "안녕", { metadata: activeStateMetadata("안녕") })], - }, - ], - } - await hooks["experimental.chat.messages.transform"]!({} as never, output as never) - - expect(calls).toBe(0) - expect((output.messages[0].parts[0] as TextPartLike).text).toBe("안녕") - }) - - test("hash mismatch in transform does not throw and does not call the translator", async () => { - let calls = 0 - const history = [ - { - info: { id: "msg_user", sessionID: "ses_1", role: "user" }, - parts: [textPart("hist", "원본", { metadata: activeStateMetadata("원본") })], - }, - ] - const hooks = createHooks( - { client: fakeClient(history), directory: "/workspace" } as never, - { model: "anthropic/claude-haiku-4-5", lang: "Korean" }, - { - translator: { - translateText: async ({ text }) => { - calls += 1 - return `EN:${text}` - }, - }, - }, - ) - - const output = { - messages: [ - { - info: { id: "msg_user", sessionID: "ses_1", role: "user" }, - parts: [textPart("hist", "편집됨", { metadata: activeStateMetadata("원본") })], - }, - ], - } - await hooks["experimental.chat.messages.transform"]!({} as never, output as never) - - expect(calls).toBe(0) - expect((output.messages[0].parts[0] as TextPartLike).text).toBe("편집됨") - }) -}) diff --git a/test/translator/part-id.test.ts b/test/translator/part-id.test.ts deleted file mode 100644 index 91babac..0000000 --- a/test/translator/part-id.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { - __resetSyntheticPartIDForTest, - __resetTranslatorCachesForTest, - createSyntheticPartID, -} from "../../src/translator" -import { encodeAscendingPartIDForTest } from "./helpers" - -describe("translator synthetic part ids", () => { - beforeEach(() => { - __resetTranslatorCachesForTest() - }) - - test("synthetic part ids use opencode's ascending part-id shape", () => { - const id = createSyntheticPartID() - const body = id.slice("prt_".length) - - expect(id.startsWith("prt_")).toBe(true) - expect(body).toHaveLength(26) - expect(body.slice(0, 12)).toMatch(/^[0-9a-f]{12}$/) - expect(body.slice(12)).toMatch(/^[0-9A-Za-z]{14}$/) - }) - - test("synthetic part ids are lexicographically ascending within one millisecond", () => { - const realDateNow = Date.now - Date.now = () => 1_700_000_000_000 - try { - __resetSyntheticPartIDForTest() - const ids = Array.from({ length: 32 }, () => createSyntheticPartID()) - expect(ids).toEqual([...ids].sort()) - } finally { - Date.now = realDateNow - } - }) - - test("synthetic part ids sort after prior timestamp-based user part ids", () => { - const realDateNow = Date.now - const userTimestamp = 1_700_000_000_000 - const userPartID = encodeAscendingPartIDForTest(userTimestamp, 1) - Date.now = () => userTimestamp + 1 - try { - __resetSyntheticPartIDForTest() - expect(createSyntheticPartID() > userPartID).toBe(true) - } finally { - Date.now = realDateNow - } - }) -}) diff --git a/test/translator/provider-retry.test.ts b/test/translator/provider-retry.test.ts deleted file mode 100644 index 2b813d4..0000000 --- a/test/translator/provider-retry.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { - __resetProviderFactoryCacheForTest, - instantiateModel, - instantiateProvider, - loadFactory, - supportsTemperature, -} from "../../src/translator/provider" - -describe("translator provider loading", () => { - beforeEach(() => { - __resetProviderFactoryCacheForTest() - }) - - test("loadFactory supports every configured provider and caches results", async () => { - const providerIDs = ["anthropic", "openai", "google", "google-vertex", "amazon-bedrock", "github-copilot"] - for (const providerID of providerIDs) { - const factory = await loadFactory(providerID) - expect(typeof factory).toBe("function") - } - - const first = await loadFactory("openai") - const second = await loadFactory("openai") - expect(second).toBe(first) - }) - - test("loadFactory rejects unsupported providers", async () => { - await expect(loadFactory("unknown-provider")).rejects.toThrow('Unsupported translator provider "unknown-provider"') - }) - - test("loadFactory handles dynamic import edge cases", async () => { - const genericFactory = await loadFactory("custom-provider", { api: { npm: "ai" } }) - expect(typeof genericFactory).toBe("function") - - await expect(loadFactory("custom-provider", { api: { npm: "node:fs/promises" } })).rejects.toThrow( - 'Unable to load provider factory from "node:fs/promises" for "custom-provider"', - ) - await expect( - loadFactory("custom-provider", { api: { npm: "missing-opencode-translate-provider" } }), - ).rejects.toThrow('Unable to load provider package "missing-opencode-translate-provider" for "custom-provider"') - }) -}) - -describe("translator provider instantiation", () => { - test("instantiateProvider passes credentials and GitHub Copilot base URL", () => { - const calls: Record[] = [] - const fetchCalls: string[] = [] - const fetchImpl = async () => new Response("ok") - const factory = (config: Record) => { - calls.push(config) - return { config } - } - - const normalProvider = instantiateProvider(factory, "openai", { apiKey: "key", fetch: fetchImpl }) as { - config: Record - } - expect(normalProvider.config.apiKey).toBe("key") - expect(typeof normalProvider.config.fetch).toBe("function") - - const observedProvider = instantiateProvider(factory, "openai", { - fetch: async (input) => { - fetchCalls.push(input instanceof URL ? input.href : String(input)) - return new Response("ok") - }, - }) as { config: Record } - ;(observedProvider.config.fetch as typeof fetch)("https://example.com") - expect(fetchCalls).toEqual(["https://example.com"]) - - instantiateProvider(factory, "github-copilot", { apiKey: "copilot-key", fetch: fetchImpl }) - expect(calls[2].apiKey).toBe("copilot-key") - expect(calls[2].name).toBe("github-copilot") - expect(calls[2].baseURL).toBe("https://api.githubcopilot.com") - expect(typeof calls[2].fetch).toBe("function") - }) - - test("instantiateProvider rejects invalid factories", () => { - expect(() => instantiateProvider({}, "openai", {})).toThrow('Invalid provider factory for "openai"') - }) - - test("instantiateProvider applies OpenCode provider and model metadata", () => { - process.env.OPENCODE_TRANSLATE_TEST_PATH = "v1" - try { - const placeholder = "$" + "{OPENCODE_TRANSLATE_TEST_PATH}" - const provider = instantiateProvider( - (config: Record) => ({ config }), - "custom-provider", - { - provider: { - id: "custom-provider", - source: "config", - env: [], - options: { headers: { "x-provider": "provider" } }, - }, - }, - { - id: "alias-model", - api: { - id: "real-model", - npm: "@ai-sdk/openai-compatible", - url: `https://example.com/${placeholder}`, - }, - headers: { "x-model": "model" }, - }, - ) as { config: Record } - - expect(provider.config.name).toBe("custom-provider") - expect(provider.config.baseURL).toBe("https://example.com/v1") - expect(provider.config.includeUsage).toBe(true) - expect(provider.config.headers).toEqual({ "x-provider": "provider", "x-model": "model" }) - } finally { - delete process.env.OPENCODE_TRANSLATE_TEST_PATH - } - }) - - test("instantiateProvider applies OpenCode fetch wrapping", async () => { - let finalBody = "" - const provider = instantiateProvider( - (config: Record) => ({ config }), - "openai", - { - provider: { - id: "openai", - source: "config", - env: [], - options: { - timeout: 1000, - chunkTimeout: 1000, - fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { - finalBody = String(init?.body) - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("data: ok\n\n")) - controller.close() - }, - }), - { status: 200, headers: { "content-type": "text/event-stream" } }, - ) - }, - }, - }, - }, - { api: { npm: "@ai-sdk/openai" } }, - ) as { config: Record } - - const response = await (provider.config.fetch as typeof fetch)("https://example.com", { - method: "POST", - signal: new AbortController().signal, - body: JSON.stringify({ input: [{ id: "item_1", text: "hello" }] }), - }) - - expect(await response.text()).toBe("data: ok\n\n") - expect(finalBody).toBe(JSON.stringify({ input: [{ text: "hello" }] })) - expect(provider.config.chunkTimeout).toBeUndefined() - }) - - test("instantiateProvider enforces SSE chunk timeout", async () => { - const provider = instantiateProvider( - (config: Record) => ({ config }), - "openai", - { - provider: { - id: "openai", - source: "config", - env: [], - options: { - chunkTimeout: 1, - fetch: async () => { - return new Response(new ReadableStream(), { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) - }, - }, - }, - }, - { api: { npm: "@ai-sdk/openai" } }, - ) as { config: Record } - - const response = await (provider.config.fetch as typeof fetch)("https://example.com") - await expect(response.text()).rejects.toThrow("SSE read timed out") - }) - - test("instantiateProvider propagates SSE read errors and cancellation", async () => { - const readErrorProvider = instantiateProvider( - (config: Record) => ({ config }), - "openai", - { - provider: { - id: "openai", - source: "config", - env: [], - options: { - chunkTimeout: 1000, - fetch: async () => { - return new Response( - new ReadableStream({ - start(controller) { - controller.error(new Error("read failed")) - }, - }), - { status: 200, headers: { "content-type": "text/event-stream" } }, - ) - }, - }, - }, - }, - { api: { npm: "@ai-sdk/openai" } }, - ) as { config: Record } - - const failedResponse = await (readErrorProvider.config.fetch as typeof fetch)("https://example.com") - await expect(failedResponse.text()).rejects.toThrow("read failed") - - let cancelledReason: unknown - const cancelProvider = instantiateProvider( - (config: Record) => ({ config }), - "openai", - { - provider: { - id: "openai", - source: "config", - env: [], - options: { - chunkTimeout: 1000, - fetch: async () => { - return new Response( - new ReadableStream({ - cancel(reason) { - cancelledReason = reason - }, - }), - { status: 200, headers: { "content-type": "text/event-stream" } }, - ) - }, - }, - }, - }, - { api: { npm: "@ai-sdk/openai" } }, - ) as { config: Record } - - const cancelledResponse = await (cancelProvider.config.fetch as typeof fetch)("https://example.com") - await cancelledResponse.body?.cancel("stop") - expect(cancelledReason).toBe("stop") - }) - - test("instantiateProvider exposes API auth to Bedrock bearer-token env", () => { - const original = process.env.AWS_BEARER_TOKEN_BEDROCK - delete process.env.AWS_BEARER_TOKEN_BEDROCK - try { - instantiateProvider((config: Record) => ({ config }), "amazon-bedrock", { - authInfo: { type: "api", key: "bedrock-token" }, - }) - expect((process.env as Record).AWS_BEARER_TOKEN_BEDROCK).toBe("bedrock-token") - } finally { - if (original === undefined) delete process.env.AWS_BEARER_TOKEN_BEDROCK - else process.env.AWS_BEARER_TOKEN_BEDROCK = original - } - }) - - test("instantiateModel supports callable, chatModel, and languageModel providers", () => { - expect(instantiateModel((id: string) => ({ kind: "callable", id }), "model-a")).toEqual({ - kind: "callable", - id: "model-a", - }) - expect(instantiateModel({ chatModel: (id: string) => ({ kind: "chat", id }) }, "model-b")).toEqual({ - kind: "chat", - id: "model-b", - }) - expect(instantiateModel({ languageModel: (id: string) => ({ kind: "language", id }) }, "model-c")).toEqual({ - kind: "language", - id: "model-c", - }) - expect(() => instantiateModel({}, "model-d")).toThrow('Unable to instantiate model "model-d"') - }) - - test("instantiateModel follows OpenCode model loader conventions", () => { - expect( - instantiateModel({ responses: (id: string) => ({ kind: "responses", id }) }, "alias-model", "openai", { - api: { id: "real-model" }, - }), - ).toEqual({ kind: "responses", id: "real-model" }) - - expect( - instantiateModel( - { responses: (id: string) => ({ kind: "responses", id }), chat: (id: string) => ({ kind: "chat", id }) }, - "gpt-5.5", - "github-copilot", - ), - ).toEqual({ kind: "responses", id: "gpt-5.5" }) - - expect( - instantiateModel( - { responses: (id: string) => ({ kind: "responses", id }), chat: (id: string) => ({ kind: "chat", id }) }, - "gpt-4o", - "github-copilot", - ), - ).toEqual({ kind: "chat", id: "gpt-4o" }) - }) - - test("instantiateModel follows Azure and Bedrock OpenCode conventions", () => { - expect( - instantiateModel({ chat: (id: string) => ({ kind: "chat", id }) }, "model", "azure", undefined, { - useCompletionUrls: true, - }), - ).toEqual({ kind: "chat", id: "model" }) - expect(instantiateModel({ responses: (id: string) => ({ kind: "responses", id }) }, "model", "azure")).toEqual({ - kind: "responses", - id: "model", - }) - expect(instantiateModel({ messages: (id: string) => ({ kind: "messages", id }) }, "model", "azure")).toEqual({ - kind: "messages", - id: "model", - }) - expect(instantiateModel({ chat: (id: string) => ({ kind: "chat", id }) }, "model", "azure")).toEqual({ - kind: "chat", - id: "model", - }) - expect(instantiateModel({ languageModel: (id: string) => ({ kind: "language", id }) }, "model", "azure")).toEqual({ - kind: "language", - id: "model", - }) - - const bedrock = { languageModel: (id: string) => ({ kind: "bedrock", id }) } - expect( - instantiateModel(bedrock, "anthropic.claude-haiku", "amazon-bedrock", undefined, { region: "us-east-1" }), - ).toEqual({ kind: "bedrock", id: "us.anthropic.claude-haiku" }) - expect( - instantiateModel(bedrock, "anthropic.claude-haiku", "amazon-bedrock", undefined, { region: "eu-west-1" }), - ).toEqual({ kind: "bedrock", id: "eu.anthropic.claude-haiku" }) - expect( - instantiateModel(bedrock, "anthropic.claude-haiku", "amazon-bedrock", undefined, { region: "ap-southeast-2" }), - ).toEqual({ kind: "bedrock", id: "au.anthropic.claude-haiku" }) - expect( - instantiateModel(bedrock, "anthropic.claude-haiku", "amazon-bedrock", undefined, { region: "ap-northeast-1" }), - ).toEqual({ kind: "bedrock", id: "jp.anthropic.claude-haiku" }) - expect(instantiateModel(bedrock, "global.anthropic.claude-haiku", "amazon-bedrock")).toEqual({ - kind: "bedrock", - id: "global.anthropic.claude-haiku", - }) - }) - - test("supportsTemperature disables unsupported OpenAI reasoning models", () => { - expect(supportsTemperature("custom", "model", { capabilities: { temperature: false } })).toBe(false) - expect(supportsTemperature("anthropic", "claude-haiku-4-5")).toBe(true) - expect(supportsTemperature("openai", "gpt-4.1")).toBe(true) - expect(supportsTemperature("openai", "gpt-5-chat-latest")).toBe(true) - expect(supportsTemperature("openai", "gpt-5.5")).toBe(false) - expect(supportsTemperature("openai", "o1-preview")).toBe(false) - expect(supportsTemperature("openai", "o3-mini")).toBe(false) - expect(supportsTemperature("openai", "o4-mini")).toBe(false) - }) -})