From 0d7b23c6f2a83db474bb53b1bb3b87600f6c36d8 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sun, 19 Apr 2026 13:13:09 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ai-provider):=20=E2=9C=A8=20add=20AI?= =?UTF-8?q?=20SDK=20multi-provider=20support=20with=20unified=20runtime=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/self-test-current-branch.yml | 26 +- .gitignore | 1 + README.md | 22 +- action.yml | 25 +- examples/example-consumer.yml | 26 +- package-lock.json | 238 ++++++++-- package.json | 11 +- scripts/verify-schema-support.js | 119 ++--- src/agents.js | 4 +- src/config.js | 74 ++- src/index.js | 15 +- src/model-runtime.js | 420 +++--------------- src/provider.js | 89 ++++ test/config.test.js | 108 ++++- test/model-runtime.test.js | 281 ++++++------ test/provider.test.js | 77 ++++ 16 files changed, 826 insertions(+), 710 deletions(-) create mode 100644 src/provider.js create mode 100644 test/provider.test.js diff --git a/.github/workflows/self-test-current-branch.yml b/.github/workflows/self-test-current-branch.yml index b132501..eed7977 100644 --- a/.github/workflows/self-test-current-branch.yml +++ b/.github/workflows/self-test-current-branch.yml @@ -14,33 +14,39 @@ jobs: self-test: runs-on: ubuntu-latest env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + LLM_API_KEY: ${{ secrets.LLM_API_KEY }} steps: - name: Checkout Current Branch uses: actions/checkout@v4 - - name: Check OPENAI_API_KEY - if: ${{ env.OPENAI_API_KEY == '' }} - run: echo "OPENAI_API_KEY is not set. Skip self-test run." + - name: Check LLM_API_KEY + if: ${{ env.LLM_API_KEY == '' }} + run: echo "LLM_API_KEY is not set. Skip self-test run." - name: Run Action From Current Branch - if: ${{ env.OPENAI_API_KEY != '' }} + if: ${{ env.LLM_API_KEY != '' }} uses: ./ with: github_token: ${{ secrets.GITHUB_TOKEN }} - openai_api_key: ${{ env.OPENAI_API_KEY }} - openai_api_base: ${{ vars.OPENAI_API_BASE }} + # Provider 配置(新字段) + ai_provider: ${{ vars.LLM_PROVIDER_PROTOCOL }} + api_key: ${{ env.LLM_API_KEY }} + api_base: ${{ vars.LLM_API_BASE }} + # [可选] Planner 使用模型;默认 gpt-5.3-codex # 可选值:任意可用模型名(字符串) - planner_model: ${{ vars.PLANNER_OPENAI_MODEL }} + planner_model: ${{ vars.PLANNER_MODEL }} # [可选] SubAgent 使用模型;默认 gpt-5.3-codex # 可选值:任意可用模型名(字符串) - reviewer_model: ${{ vars.DEFAULT_OPENAI_MODEL }} + reviewer_model: ${{ vars.DEFAULT_MODEL }} - openai_api_base_allowlist: | + api_base_allowlist: | api.openai.com zenmux.ai + opencode.ai + tokenhub.tencentmaas.com + api.lkeap.cloud.tencent.com include: | **/*.js diff --git a/.gitignore b/.gitignore index c5b0423..f571bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ npm-debug.log* .DS_Store .env +.firecrawl diff --git a/README.md b/README.md index 0f99dd9..221e6ce 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,13 @@ jobs: runs-on: ubuntu-latest steps: - name: AI Code Review - uses: TiyAgents/code-review-agent-action@v2 + uses: TiyAgents/code-review-agent-action@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - openai_api_key: ${{ secrets.OPENAI_API_KEY }} - openai_api_base: ${{ vars.OPENAI_API_BASE }} - openai_api_base_allowlist: | + ai_provider: openai + api_key: ${{ secrets.OPENAI_API_KEY }} + api_base: ${{ vars.OPENAI_API_BASE }} + api_base_allowlist: | api.openai.com your-gateway.example.com include: | @@ -78,7 +79,6 @@ jobs: **/*.min.js planner_model: gpt-5.3-codex reviewer_model: gpt-5.3-codex - llm_compatibility_mode: auto review_dimensions: general,security,performance,testing review_language: English min_finding_confidence: 0.72 @@ -99,14 +99,18 @@ jobs: | Name | Required | Default | Description | | --- | --- | --- | --- | | `github_token` | yes | - | GitHub token with review/comment write permissions | -| `openai_api_key` | no | env `OPENAI_API_KEY` | OpenAI API key | -| `openai_api_base` | no | env `OPENAI_API_BASE` | Optional custom OpenAI-compatible base URL | -| `openai_api_base_allowlist` | no | `api.openai.com` | Allowed hostnames for `openai_api_base` (HTTPS only) | +| `ai_provider` | no | `openai` | AI provider type: `openai`, `anthropic`, `google`, `mistral`, or `openai-compatible` | +| `api_key` | no | env `OPENAI_API_KEY` | API key for the selected AI provider | +| `api_base` | no | env `OPENAI_API_BASE` | Optional base URL for the AI provider API endpoint | +| `api_base_allowlist` | no | `api.openai.com` | Allowed hostnames for `api_base` (HTTPS only) | +| `openai_api_key` | no | - | **Deprecated**: use `api_key` | +| `openai_api_base` | no | - | **Deprecated**: use `api_base` | +| `openai_api_base_allowlist` | no | - | **Deprecated**: use `api_base_allowlist` | | `include` | no | `**` | Include globs (comma/newline separated) | | `exclude` | no | empty | Exclude globs (comma/newline separated) | | `planner_model` | no | `gpt-5.3-codex` | Planner model | | `reviewer_model` | no | `gpt-5.3-codex` | Subagent model | -| `llm_compatibility_mode` | no | `auto` | Structured-output compatibility mode: `auto`, `responses_json_schema`, `chat_json_schema`, `chat_json_object`, or `prompt_json` | +| `llm_compatibility_mode` | no | `auto` | **Deprecated**: AI SDK handles compatibility automatically | | `review_dimensions` | no | `general,security,performance,testing` | Subagent dimensions | | `review_language` | no | `English` | Preferred language for review comments and summary | | `min_finding_confidence` | no | `0.72` | Keep only findings at or above this confidence (0-1) | diff --git a/action.yml b/action.yml index 65e5f2d..c09fa29 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ name: Inline PR Review Agent -description: PR code review action using an OpenAI-compatible structured runtime with inline comments and summary updates. +description: PR code review action powered by AI SDK with multi-provider support (OpenAI, Anthropic, Google, Mistral, OpenAI-compatible) and structured output. author: TiyAgents Team @@ -12,16 +12,29 @@ inputs: github_token: description: GitHub token with pull-requests:write and issues:write permissions. required: true + ai_provider: + description: AI provider type (openai|anthropic|google|mistral|openai-compatible). + required: false + default: openai + api_key: + description: API key for the selected AI provider. + required: false + api_base: + description: Optional base URL for the AI provider API endpoint. + required: false openai_api_key: - description: OpenAI API key. Falls back to OPENAI_API_KEY env. + description: "[Deprecated: use api_key] OpenAI API key. Falls back to OPENAI_API_KEY env." required: false openai_api_base: - description: Optional OpenAI API base URL. Falls back to OPENAI_API_BASE env. + description: "[Deprecated: use api_base] Optional OpenAI API base URL. Falls back to OPENAI_API_BASE env." required: false - openai_api_base_allowlist: - description: Comma/newline separated allowed hostnames for openai_api_base (HTTPS only). + api_base_allowlist: + description: Comma/newline separated allowed hostnames for api_base (HTTPS only). required: false default: api.openai.com + openai_api_base_allowlist: + description: "[Deprecated: use api_base_allowlist] Comma/newline separated allowed hostnames for api_base (HTTPS only)." + required: false include: description: Include globs (comma/newline separated). required: false @@ -39,7 +52,7 @@ inputs: required: false default: gpt-5.3-codex llm_compatibility_mode: - description: Compatibility mode for OpenAI-compatible model APIs (auto|responses_json_schema|chat_json_schema|chat_json_object|prompt_json). + description: "[Deprecated: AI SDK handles compatibility automatically] Compatibility mode for OpenAI-compatible model APIs." required: false default: auto review_dimensions: diff --git a/examples/example-consumer.yml b/examples/example-consumer.yml index c1967c9..1cdd8c2 100644 --- a/examples/example-consumer.yml +++ b/examples/example-consumer.yml @@ -20,22 +20,26 @@ jobs: - name: Run AI Code Review Action if: ${{ env.OPENAI_API_KEY != '' }} - uses: TiyAgents/code-review-agent-action@v2 + uses: TiyAgents/code-review-agent-action@v3 with: # [Required] GitHub token with pull-requests:write and issues:write permissions github_token: ${{ secrets.GITHUB_TOKEN }} - # [Conditionally required] OpenAI key. If not provided here, - # it must be available via OPENAI_API_KEY environment variable - openai_api_key: ${{ env.OPENAI_API_KEY }} + # [Optional] AI provider type; default is openai + # Allowed values: openai, anthropic, google, mistral, openai-compatible + ai_provider: openai - # [Optional] OpenAI base URL; any OpenAI-compatible API base URL - # If omitted, default official base is used; can also come from OPENAI_API_BASE - openai_api_base: ${{ vars.OPENAI_API_BASE }} + # [Conditionally required] API key for the selected provider. + # If not provided here, falls back to openai_api_key input or OPENAI_API_KEY env + api_key: ${{ env.OPENAI_API_KEY }} - # [Optional] Allowed hosts for openai_api_base (comma/newline separated); default api.openai.com + # [Optional] Base URL for the AI provider API endpoint + # If omitted, default official base is used; can also come from OPENAI_API_BASE env + api_base: ${{ vars.OPENAI_API_BASE }} + + # [Optional] Allowed hosts for api_base (comma/newline separated); default api.openai.com # Required when using a custom OpenAI-compatible gateway host - openai_api_base_allowlist: | + api_base_allowlist: | api.openai.com your-gateway.example.com @@ -61,10 +65,6 @@ jobs: # Allowed values: any available model name (string) reviewer_model: gpt-5.3-codex - # [Optional] Compatibility mode for OpenAI-compatible APIs; default is auto - # Allowed values: auto, responses_json_schema, chat_json_schema, chat_json_object, prompt_json - llm_compatibility_mode: auto - # [Optional] Review dimensions (comma or newline separated); # default is general,security,performance,testing # Suggested values: general, security, performance, testing diff --git a/package-lock.json b/package-lock.json index 16fa13c..1615779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,22 @@ { "name": "ai-code-review-agent-action", - "version": "0.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-code-review-agent-action", - "version": "0.1.0", + "version": "3.0.0", "dependencies": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.1", + "@ai-sdk/anthropic": "^3.0.71", + "@ai-sdk/google": "^3.0.64", + "@ai-sdk/mistral": "^3.0.30", + "@ai-sdk/openai": "^3.0.53", + "@ai-sdk/openai-compatible": "^2.0.41", + "ai": "^6.0.168", "minimatch": "^10.1.1", - "openai": "^6.5.0", "zod": "^3.25.76" }, "engines": { @@ -68,6 +73,132 @@ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", "license": "MIT" }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.71", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.71.tgz", + "integrity": "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.104", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.104.tgz", + "integrity": "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.64", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.64.tgz", + "integrity": "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mistral": { + "version": "3.0.30", + "resolved": "https://registry.npmjs.org/@ai-sdk/mistral/-/mistral-3.0.30.tgz", + "integrity": "sha512-+j4IXRSk9E661cFSafmIr+XHOzwjFagawwzMOlSqwL6U4Sq4PCFLDF+oHbX5NUqNjUL7FD1zi/9lBIfa41pUvw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.53", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.53.tgz", + "integrity": "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "2.0.41", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.41.tgz", + "integrity": "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", + "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -235,6 +366,48 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "6.0.168", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.168.tgz", + "integrity": "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==", + "license": "Apache-2.0", + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -268,6 +441,21 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "license": "ISC" }, + "node_modules/eventsource-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.7.tgz", + "integrity": "sha512-zwxwiQqexizSXFZV13zMiEtW1E3lv7RlUv+1f5FBiR4x7wFhEjm3aFTyYkZQWzyN08WnPdox015GoRH5D/E5YA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -292,27 +480,6 @@ "wrappy": "1" } }, - "node_modules/openai": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", - "integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -346,29 +513,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 12bf829..467eaa4 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "ai-code-review-agent-action", - "version": "2.0.0", + "version": "3.0.0", "private": true, - "description": "Reusable GitHub Action for AI code review with an OpenAI-compatible structured runtime", + "description": "Reusable GitHub Action for AI code review with multi-provider support via AI SDK", "main": "src/index.js", "scripts": { "start": "node src/index.js", @@ -16,8 +16,13 @@ "dependencies": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.1", + "@ai-sdk/anthropic": "^3.0.71", + "@ai-sdk/google": "^3.0.64", + "@ai-sdk/mistral": "^3.0.30", + "@ai-sdk/openai": "^3.0.53", + "@ai-sdk/openai-compatible": "^2.0.41", + "ai": "^6.0.168", "minimatch": "^10.1.1", - "openai": "^6.5.0", "zod": "^3.25.76" } } diff --git a/scripts/verify-schema-support.js b/scripts/verify-schema-support.js index 846b84b..d3557a5 100644 --- a/scripts/verify-schema-support.js +++ b/scripts/verify-schema-support.js @@ -4,12 +4,12 @@ const fs = require('node:fs'); const path = require('node:path'); const { - configureOpenAIClient, + configureRuntime, createPlannerAgent, createReviewerAgent, runStructuredWithRepair } = require('../src/agents'); -const { COMPATIBILITY_MODES } = require('../src/model-runtime'); +const { createProvider, createModel } = require('../src/provider'); function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) { @@ -73,34 +73,19 @@ function parseModelList(value) { return models; } -function parseModeList(value) { - const allowed = new Set(COMPATIBILITY_MODES.filter((mode) => mode !== 'auto')); - const selected = []; - for (const entry of String(value || '').split(/[|,]/)) { - const mode = entry.trim(); - if (!mode || !allowed.has(mode) || selected.includes(mode)) { - continue; - } - selected.push(mode); - } - return selected.length > 0 - ? selected - : ['responses_json_schema', 'chat_json_schema', 'chat_json_object', 'prompt_json']; -} - function summarizeError(error) { return String(error?.message || error || 'unknown_error').slice(0, 220); } -async function runCase({ model, baseURL, apiKey, mode, name, agent, input }) { - configureOpenAIClient({ apiKey, baseURL, compatibilityMode: mode }); +async function runCase({ provider, model, name, agent, input }) { + const modelInstance = createModel(provider, model); + configureRuntime({ model: modelInstance }); const result = await runStructuredWithRepair(agent, input, { allowRepair: true }); if (result.ok) { return { ok: true, name, - mode, repaired: result.repaired }; } @@ -108,7 +93,6 @@ async function runCase({ model, baseURL, apiKey, mode, name, agent, input }) { return { ok: false, name, - mode, error: summarizeError(result.error) }; } @@ -117,14 +101,14 @@ async function main() { const cwd = process.cwd(); loadEnvFile(path.join(cwd, '.env')); - const apiKey = process.env.OPENAI_API_KEY; - const baseURL = process.env.OPENAI_API_BASE || ''; + const apiKey = process.env.OPENAI_API_KEY || process.env.API_KEY || ''; + const baseURL = process.env.OPENAI_API_BASE || process.env.API_BASE || ''; + const providerType = process.env.AI_PROVIDER || 'openai'; const modelInput = process.env.MODEL || process.env.OPENAI_MODEL || ''; - const modes = parseModeList(process.env.COMPATIBILITY_MODES || ''); const bugProbeRequired = parseBooleanEnv('BUG_PROBE_REQUIRED', false); if (!apiKey) { - throw new Error('Missing OPENAI_API_KEY. Set it in environment or .env.'); + throw new Error('Missing API key. Set OPENAI_API_KEY or API_KEY in environment or .env.'); } const models = parseModelList(modelInput); @@ -132,8 +116,14 @@ async function main() { throw new Error('Missing MODEL. Set MODEL in environment or .env. Use "|" to test multiple models.'); } + const provider = createProvider({ + provider: providerType, + apiKey, + baseURL: baseURL || undefined + }); + console.log( - `Compatibility check start: models=${models.join('|')}${baseURL ? ` base=${baseURL}` : ''} modes=${modes.join(',')}` + `Compatibility check start: provider=${providerType} models=${models.join('|')}${baseURL ? ` base=${baseURL}` : ''}` ); const plannerPrompt = [ @@ -207,65 +197,52 @@ async function main() { projectGuidance: null }); - const modeResults = []; - for (const mode of modes) { - console.log(`-- mode: ${mode}`); - const cases = [ - { name: 'planner', agent: planner, input: plannerPrompt }, - { name: 'reviewer', agent: reviewer, input: reviewerPrompt }, - { name: 'bug_probe', agent: reviewer, input: bugProbePrompt } - ]; - - const results = []; - for (const testCase of cases) { - process.stdout.write(`- ${testCase.name}: `); - const result = await runCase({ - model, - baseURL, - apiKey, - mode, - ...testCase - }); - results.push(result); - if (result.ok) { - process.stdout.write(`PASS${result.repaired ? ' (repaired)' : ''}\n`); - } else { - process.stdout.write(`FAIL (${result.error})\n`); - } + const cases = [ + { name: 'planner', agent: planner, input: plannerPrompt }, + { name: 'reviewer', agent: reviewer, input: reviewerPrompt }, + { name: 'bug_probe', agent: reviewer, input: bugProbePrompt } + ]; + + const results = []; + for (const testCase of cases) { + process.stdout.write(`- ${testCase.name}: `); + const result = await runCase({ + provider, + model, + ...testCase + }); + results.push(result); + if (result.ok) { + process.stdout.write(`PASS${result.repaired ? ' (repaired)' : ''}\n`); + } else { + process.stdout.write(`FAIL (${result.error})\n`); } - - modeResults.push({ mode, results }); } - const recommended = modeResults.find(({ results }) => { - const plannerOk = results.find((item) => item.name === 'planner')?.ok; - const reviewerOk = results.find((item) => item.name === 'reviewer')?.ok; - return plannerOk && reviewerOk; - }); + const plannerOk = results.find((item) => item.name === 'planner')?.ok; + const reviewerOk = results.find((item) => item.name === 'reviewer')?.ok; - if (recommended) { - console.log(`Recommended mode: ${recommended.mode}`); + if (plannerOk && reviewerOk) { + console.log('Result: PASS'); } else { - console.log('Recommended mode: none'); + console.log('Result: FAIL'); } - for (const { mode, results } of modeResults) { - const hardFailures = results - .filter((item) => !item.ok && (item.name !== 'bug_probe' || bugProbeRequired)) - .map((item) => ({ ...item, model, mode })); - allHardFailures.push(...hardFailures); + const hardFailures = results + .filter((item) => !item.ok && (item.name !== 'bug_probe' || bugProbeRequired)) + .map((item) => ({ ...item, model })); + allHardFailures.push(...hardFailures); - const bugProbeResult = results.find((item) => item.name === 'bug_probe'); - if (bugProbeResult && !bugProbeResult.ok && !bugProbeRequired) { - console.warn(`Bug probe (${mode}): FAIL (non-blocking).`); - } + const bugProbeResult = results.find((item) => item.name === 'bug_probe'); + if (bugProbeResult && !bugProbeResult.ok && !bugProbeRequired) { + console.warn('Bug probe: FAIL (non-blocking).'); } } if (allHardFailures.length > 0) { console.error('\nCompatibility check failed.'); for (const item of allHardFailures) { - console.error(`- [${item.model}] [${item.mode}] ${item.name}: ${item.error}`); + console.error(`- [${item.model}] ${item.name}: ${item.error}`); } process.exitCode = 1; return; diff --git a/src/agents.js b/src/agents.js index 64e40a6..28baf2d 100644 --- a/src/agents.js +++ b/src/agents.js @@ -1,5 +1,5 @@ const { z } = require('zod'); -const { configureOpenAIClient, runStructuredWithRepair } = require('./model-runtime'); +const { configureRuntime, runStructuredWithRepair } = require('./model-runtime'); const plannerOutputSchema = z.object({ batches: z @@ -325,7 +325,7 @@ function buildBatchReviewInput({ dimension, round, batchFiles, maxContextChars, } module.exports = { - configureOpenAIClient, + configureRuntime, createPlannerAgent, createReviewerAgent, runStructuredWithRepair, diff --git a/src/config.js b/src/config.js index d6a7c93..3c59878 100644 --- a/src/config.js +++ b/src/config.js @@ -1,5 +1,5 @@ const core = require('@actions/core'); -const { COMPATIBILITY_MODES } = require('./model-runtime'); +const { SUPPORTED_PROVIDERS } = require('./provider'); const DEFAULT_SUMMARY_MARKER = 'ai-code-review-agent:summary'; const DEFAULT_REVIEW_MARKER = 'ai-code-review-agent:review'; @@ -15,8 +15,8 @@ function normalizeHost(value) { return String(value || '').trim().toLowerCase(); } -function validateOpenAIBaseURL(openaiApiBase, allowedHosts) { - const raw = String(openaiApiBase || '').trim(); +function validateBaseURL(baseURL, allowedHosts) { + const raw = String(baseURL || '').trim(); if (!raw) { return ''; } @@ -25,23 +25,25 @@ function validateOpenAIBaseURL(openaiApiBase, allowedHosts) { try { parsed = new URL(raw); } catch { - throw new Error(`Input openai_api_base must be a valid URL, got: ${raw}`); + throw new Error(`Input api_base must be a valid URL, got: ${raw}`); } if (parsed.protocol !== 'https:') { - throw new Error(`Input openai_api_base must use https scheme, got: ${parsed.protocol}`); + throw new Error(`Input api_base must use https scheme, got: ${parsed.protocol}`); } if (parsed.username || parsed.password) { - throw new Error('Input openai_api_base must not contain username/password credentials.'); + throw new Error('Input api_base must not contain username/password credentials.'); } - const host = normalizeHost(parsed.hostname); - const allow = new Set((allowedHosts || []).map(normalizeHost).filter(Boolean)); - if (!allow.has(host)) { - throw new Error( - `Input openai_api_base host is not in allowlist: ${host}. ` + - 'Set openai_api_base_allowlist to explicitly trust this host.' - ); + if (allowedHosts && allowedHosts.length > 0) { + const host = normalizeHost(parsed.hostname); + const allow = new Set(allowedHosts.map(normalizeHost).filter(Boolean)); + if (!allow.has(host)) { + throw new Error( + `Input api_base host is not in allowlist: ${host}. ` + + 'Set openai_api_base_allowlist to explicitly trust this host.' + ); + } } return raw; @@ -101,15 +103,38 @@ function uniqueLowercase(items) { function loadConfig() { const githubToken = core.getInput('github_token', { required: true }); - const openaiApiKey = core.getInput('openai_api_key') || process.env.OPENAI_API_KEY; - const openaiApiBaseRaw = core.getInput('openai_api_base') || process.env.OPENAI_API_BASE || ''; - const openaiApiBaseAllowlist = splitListInput( - core.getInput('openai_api_base_allowlist') || process.env.OPENAI_API_BASE_ALLOWLIST || 'api.openai.com' + + // Provider configuration with backward compatibility + const aiProvider = parseEnumInput('ai_provider', 'openai', SUPPORTED_PROVIDERS); + const apiKey = core.getInput('api_key') + || core.getInput('openai_api_key') + || process.env.OPENAI_API_KEY + || ''; + const apiBaseRaw = core.getInput('api_base') + || core.getInput('openai_api_base') + || process.env.OPENAI_API_BASE + || ''; + const apiBaseAllowlist = splitListInput( + core.getInput('api_base_allowlist') + || core.getInput('openai_api_base_allowlist') + || process.env.OPENAI_API_BASE_ALLOWLIST + || 'api.openai.com' ); - const openaiApiBase = validateOpenAIBaseURL(openaiApiBaseRaw, openaiApiBaseAllowlist); + const apiBase = validateBaseURL(apiBaseRaw, apiBaseAllowlist); - if (!openaiApiKey) { - throw new Error('Missing OpenAI API key. Provide input openai_api_key or OPENAI_API_KEY env.'); + if (!apiKey) { + throw new Error( + 'Missing API key. Provide input api_key (or openai_api_key for backward compatibility) or set OPENAI_API_KEY env.' + ); + } + + // Warn about deprecated llm_compatibility_mode + const llmCompatRaw = core.getInput('llm_compatibility_mode') || ''; + if (llmCompatRaw && llmCompatRaw.trim().toLowerCase() !== 'auto') { + core.warning( + 'Input llm_compatibility_mode is deprecated and will be ignored. ' + + 'AI SDK handles provider compatibility automatically.' + ); } const include = splitListInput(core.getInput('include') || '**'); @@ -123,14 +148,15 @@ function loadConfig() { return { githubToken, - openaiApiKey, - openaiApiBase, - openaiApiBaseAllowlist, + aiProvider, + apiKey, + apiBase, + openaiApiBaseAllowlist: apiBaseAllowlist, + apiBaseAllowlist, include, exclude, plannerModel: core.getInput('planner_model') || 'gpt-5.3-codex', reviewerModel: core.getInput('reviewer_model') || 'gpt-5.3-codex', - llmCompatibilityMode: parseEnumInput('llm_compatibility_mode', 'auto', COMPATIBILITY_MODES), reviewDimensions: normalizedDimensions, reviewLanguage, minFindingConfidence: parseFloatRangeInput('min_finding_confidence', 0.72, 0, 1), diff --git a/src/index.js b/src/index.js index c8882e5..2bc8214 100644 --- a/src/index.js +++ b/src/index.js @@ -6,13 +6,14 @@ const { filterFiles } = require('./globs'); const { buildDiffLineMaps, resolveInlineLocation } = require('./diff-map'); const { inlineKeyFromFinding } = require('./inline-key'); const { - configureOpenAIClient, + configureRuntime, createPlannerAgent, createReviewerAgent, runStructuredWithRepair, buildPlannerInput, buildBatchReviewInput } = require('./agents'); +const { createProvider, createModel } = require('./provider'); const { normalizeFindings, dedupeAndSortFindings, @@ -490,12 +491,14 @@ async function runAction() { let subAgentRuns = 0; if (patchFiles.length > 0) { - configureOpenAIClient({ - apiKey: config.openaiApiKey, - baseURL: config.openaiApiBase || undefined, - compatibilityMode: config.llmCompatibilityMode + const provider = createProvider({ + provider: config.aiProvider, + apiKey: config.apiKey, + baseURL: config.apiBase || undefined }); - core.info(`LLM compatibility mode: ${config.llmCompatibilityMode}`); + const plannerModelInstance = createModel(provider, config.plannerModel); + configureRuntime({ model: plannerModelInstance }); + core.info(`AI provider: ${config.aiProvider}`); const planner = createPlannerAgent({ model: config.plannerModel, diff --git a/src/model-runtime.js b/src/model-runtime.js index 454a031..0eec285 100644 --- a/src/model-runtime.js +++ b/src/model-runtime.js @@ -1,125 +1,19 @@ -const OpenAIImport = require('openai'); -const { zodTextFormat } = require('openai/helpers/zod'); - -const OpenAI = OpenAIImport.default || OpenAIImport; - -const COMPATIBILITY_MODES = [ - 'auto', - 'responses_json_schema', - 'chat_json_schema', - 'chat_json_object', - 'prompt_json' -]; - -const OFFICIAL_OPENAI_HOST = 'api.openai.com'; -const SUCCESS_MODE_CACHE = new Map(); +const { generateText, Output } = require('ai'); let runtimeState = { - client: null, - apiKey: '', - baseURL: '', - compatibilityMode: 'auto' + model: null }; -function configureOpenAIClient({ apiKey, baseURL, compatibilityMode }) { - runtimeState = { - client: new OpenAI({ - apiKey, - ...(baseURL ? { baseURL } : {}) - }), - apiKey, - baseURL: baseURL || '', - compatibilityMode: compatibilityMode || 'auto' - }; - SUCCESS_MODE_CACHE.clear(); -} - -function getConfiguredHost(baseURL) { - if (!baseURL) { - return OFFICIAL_OPENAI_HOST; - } - - try { - return new URL(baseURL).hostname.toLowerCase(); - } catch { - return OFFICIAL_OPENAI_HOST; +/** + * Configure the AI SDK runtime with a model instance. + * + * @param {{ model: import('ai').LanguageModel }} opts + */ +function configureRuntime({ model }) { + if (!model) { + throw new Error('A valid AI SDK model instance is required.'); } -} - -function isOfficialHost(baseURL) { - return getConfiguredHost(baseURL) === OFFICIAL_OPENAI_HOST; -} - -function getCompatibilityModes({ model, baseURL, configuredMode }) { - const explicitMode = String(configuredMode || 'auto').trim(); - if (explicitMode && explicitMode !== 'auto') { - return [explicitMode]; - } - - const cacheKey = `${getConfiguredHost(baseURL)}|${model}`; - const cached = SUCCESS_MODE_CACHE.get(cacheKey); - const defaults = isOfficialHost(baseURL) - ? ['responses_json_schema', 'chat_json_schema', 'chat_json_object', 'prompt_json'] - : ['chat_json_object', 'chat_json_schema', 'prompt_json', 'responses_json_schema']; - - if (!cached) { - return defaults; - } - - return [cached, ...defaults.filter((mode) => mode !== cached)]; -} - -function cacheSuccessfulMode({ model, baseURL, mode }) { - SUCCESS_MODE_CACHE.set(`${getConfiguredHost(baseURL)}|${model}`, mode); -} - -function clearCachedMode({ model, baseURL }) { - SUCCESS_MODE_CACHE.delete(`${getConfiguredHost(baseURL)}|${model}`); -} - -function getJsonSchemaFormat(agent) { - if (!agent._jsonSchemaFormat) { - const raw = zodTextFormat(agent.schema, agent.responseName || 'output'); - agent._jsonSchemaFormat = { - type: raw.type, - name: raw.name, - strict: raw.strict, - schema: sanitizeJsonSchema(raw.schema) - }; - } - return agent._jsonSchemaFormat; -} - -function sanitizeJsonSchema(schema) { - if (Array.isArray(schema)) { - return schema.map((item) => sanitizeJsonSchema(item)); - } - if (!schema || typeof schema !== 'object') { - return schema; - } - - const cleaned = {}; - for (const [key, value] of Object.entries(schema)) { - if (['$schema', 'default', 'title', 'description', 'examples'].includes(key)) { - continue; - } - - cleaned[key] = value && typeof value === 'object' - ? sanitizeJsonSchema(value) - : value; - } - - if (cleaned.type === 'integer' && Number.isFinite(cleaned.exclusiveMinimum)) { - cleaned.minimum = cleaned.exclusiveMinimum + 1; - delete cleaned.exclusiveMinimum; - } - - if (cleaned.type === 'integer' && Number.isFinite(cleaned.exclusiveMaximum)) { - cleaned.maximum = cleaned.exclusiveMaximum - 1; - delete cleaned.exclusiveMaximum; - } - - return cleaned; + runtimeState = { model }; } function buildUserInput(agent, input, repairContext) { @@ -142,90 +36,30 @@ function buildUserInput(agent, input, repairContext) { return blocks.filter((block, index, items) => block || (index > 0 && items[index - 1] !== '')).join('\n'); } -function buildPromptJsonMessage(agent, input, repairContext) { - const blocks = [ - 'Follow these task instructions exactly.', - agent.instructions, - '', - 'Required JSON contract:', - agent.outputContractPrompt, - '', - 'Task input:', - String(input || '').trim(), - '', - 'Return exactly one JSON object with no markdown, no code fences, and no surrounding explanation.' - ]; - - if (repairContext) { - blocks.push(''); - blocks.push('The previous attempt was invalid. Repair it.'); - blocks.push(`Validation error: ${repairContext.error}`); - if (repairContext.preview) { - blocks.push(`Previous output preview: ${repairContext.preview}`); - } - } - - return blocks.join('\n'); -} - +/** + * Run a structured output call with AI SDK, with one repair retry on failure. + * + * @param {object} agent Agent definition with { model, instructions, outputContractPrompt, schema } + * @param {string} input User prompt input + * @param {{ allowRepair?: boolean }} options + * @returns {Promise<{ ok: boolean, output?: any, error?: Error, calls: number, repaired: boolean }>} + */ async function runStructuredWithRepair(agent, input, options = {}) { - if (!runtimeState.client) { - throw new Error('OpenAI client is not configured. Call configureOpenAIClient first.'); + if (!runtimeState.model) { + throw new Error('Runtime is not configured. Call configureRuntime first.'); } const allowRepair = options.allowRepair !== false; - const modes = getCompatibilityModes({ - model: agent.model, - baseURL: runtimeState.baseURL, - configuredMode: runtimeState.compatibilityMode - }); - let totalCalls = 0; - let repaired = false; - const failures = []; - - for (const mode of modes) { - const modeResult = await runWithMode(agent, input, { - mode, - allowRepair, - client: runtimeState.client - }); - totalCalls += modeResult.calls; - repaired = repaired || modeResult.repaired; - - if (modeResult.ok) { - cacheSuccessfulMode({ model: agent.model, baseURL: runtimeState.baseURL, mode }); - return { - ok: true, - output: modeResult.output, - calls: totalCalls, - repaired, - mode - }; - } - - clearCachedMode({ model: agent.model, baseURL: runtimeState.baseURL }); - failures.push(`${mode}: ${modeResult.error.message || String(modeResult.error)}`); - } - - return { - ok: false, - error: new Error(`Structured output failed across modes: ${failures.join(' | ')}`), - calls: totalCalls, - repaired - }; -} - -async function runWithMode(agent, input, { mode, allowRepair, client }) { - let calls = 0; + // First attempt try { - calls += 1; - const output = await requestStructuredOutput({ client, agent, input, mode, repairContext: null }); - return { ok: true, output, calls, repaired: false }; + totalCalls += 1; + const output = await requestStructuredOutput({ agent, input, repairContext: null }); + return { ok: true, output, calls: totalCalls, repaired: false }; } catch (firstError) { - if (isModeUnsupportedError(firstError) || !allowRepair) { - return { ok: false, error: firstError, calls, repaired: false, unsupportedMode: isModeUnsupportedError(firstError) }; + if (!allowRepair) { + return { ok: false, error: firstError, calls: totalCalls, repaired: false }; } const repairContext = { @@ -233,27 +67,41 @@ async function runWithMode(agent, input, { mode, allowRepair, client }) { preview: String(firstError.preview || '').slice(0, 300) }; + // Repair attempt try { - calls += 1; - const repairedOutput = await requestStructuredOutput({ client, agent, input, mode, repairContext }); - return { ok: true, output: repairedOutput, calls, repaired: true }; + totalCalls += 1; + const repairedOutput = await requestStructuredOutput({ agent, input, repairContext }); + return { ok: true, output: repairedOutput, calls: totalCalls, repaired: true }; } catch (secondError) { return { ok: false, error: new Error(`Structured output failed after repair: ${compactErrorMessage(secondError)}`), - calls, - repaired: true, - unsupportedMode: isModeUnsupportedError(secondError) + calls: totalCalls, + repaired: true }; } } } -async function requestStructuredOutput({ client, agent, input, mode, repairContext }) { - const request = buildRequest({ client, agent, input, mode, repairContext }); - const response = await request.execute(); - const text = request.extractText(response); +async function requestStructuredOutput({ agent, input, repairContext }) { + const userPrompt = buildUserInput(agent, input, repairContext); + + const result = await generateText({ + model: runtimeState.model, + system: agent.instructions, + prompt: userPrompt, + output: Output.object({ schema: agent.schema }) + }); + + // AI SDK sets output to the parsed object when successful + if (result.output !== undefined && result.output !== null) { + return result.output; + } + // Fallback: some providers may return text but fail structured parsing. + // AI SDK sets output=null when schema validation fails internally, + // so we attempt manual JSON extraction from the raw text as a safety net. + const text = (result.text || '').trim(); if (!text) { const error = new Error('Model returned empty output text.'); error.code = 'empty_output'; @@ -263,8 +111,8 @@ async function requestStructuredOutput({ client, agent, input, mode, repairConte let parsedObject; try { parsedObject = JSON.parse(extractJsonObjectText(text)); - } catch (error) { - const wrapped = new Error(`Model output is not valid JSON: ${error.message || String(error)}`); + } catch (parseError) { + const wrapped = new Error(`Model output is not valid JSON: ${parseError.message || String(parseError)}`); wrapped.code = 'invalid_json'; wrapped.preview = text.slice(0, 400); throw wrapped; @@ -281,131 +129,6 @@ async function requestStructuredOutput({ client, agent, input, mode, repairConte return parsed.data; } -function buildRequest({ client, agent, input, mode, repairContext }) { - const jsonSchemaFormat = getJsonSchemaFormat(agent); - const userInput = buildUserInput(agent, input, repairContext); - - if (mode === 'responses_json_schema') { - const requestData = { - model: agent.model, - instructions: agent.instructions, - input: userInput, - text: { - format: jsonSchemaFormat - } - }; - - return { - execute: () => client.responses.create(requestData), - extractText: extractResponsesText - }; - } - - if (mode === 'chat_json_schema') { - const requestData = { - model: agent.model, - messages: [ - { role: 'system', content: agent.instructions }, - { role: 'user', content: userInput } - ], - response_format: { - type: 'json_schema', - json_schema: { - name: jsonSchemaFormat.name, - strict: jsonSchemaFormat.strict, - schema: jsonSchemaFormat.schema - } - } - }; - - return { - execute: () => client.chat.completions.create(requestData), - extractText: extractChatCompletionsText - }; - } - - if (mode === 'chat_json_object') { - const requestData = { - model: agent.model, - messages: [ - { role: 'system', content: agent.instructions }, - { role: 'user', content: userInput } - ], - response_format: { - type: 'json_object' - } - }; - - return { - execute: () => client.chat.completions.create(requestData), - extractText: extractChatCompletionsText - }; - } - - if (mode === 'prompt_json') { - const requestData = { - model: agent.model, - messages: [ - { - role: 'user', - content: buildPromptJsonMessage(agent, input, repairContext) - } - ] - }; - - return { - execute: () => client.chat.completions.create(requestData), - extractText: extractChatCompletionsText - }; - } - - throw new Error(`Unknown compatibility mode: ${mode}`); -} - -function extractResponsesText(response) { - if (typeof response?.output_text === 'string' && response.output_text.trim()) { - return response.output_text.trim(); - } - - const chunks = []; - for (const item of response?.output || []) { - if (item?.type !== 'message') { - continue; - } - for (const content of item.content || []) { - if (content?.type === 'output_text' && typeof content.text === 'string') { - chunks.push(content.text); - } - } - } - - return chunks.join('\n').trim(); -} - -function extractChatCompletionsText(response) { - const content = response?.choices?.[0]?.message?.content; - if (typeof content === 'string') { - return content.trim(); - } - - if (Array.isArray(content)) { - return content - .map((item) => { - if (typeof item === 'string') { - return item; - } - if (item && typeof item.text === 'string') { - return item.text; - } - return ''; - }) - .join('\n') - .trim(); - } - - return ''; -} - function stripCodeFences(text) { const trimmed = String(text || '').trim(); const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); @@ -469,48 +192,13 @@ function compactErrorMessage(error) { return String(error?.message || error || 'unknown_error'); } -function isModeUnsupportedError(error) { - const message = compactErrorMessage(error).toLowerCase(); - const unsupportedHints = [ - 'not supported', - 'unsupported', - 'unknown field', - 'unrecognized field', - 'extra inputs are not permitted', - 'invalid field', - 'invalid parameter', - 'response_format', - 'output_config.format.schema', - 'text.format', - 'json_schema', - 'json object response format is not supported', - 'instructions', - 'responses api', - 'chat.completions' - ]; - - return unsupportedHints.some((hint) => message.includes(hint)); -} - module.exports = { - COMPATIBILITY_MODES, - configureOpenAIClient, - getCompatibilityModes, + configureRuntime, runStructuredWithRepair, - sanitizeJsonSchema, extractJsonObjectText, - extractResponsesText, - extractChatCompletionsText, - isModeUnsupportedError, __private: { - SUCCESS_MODE_CACHE, - getConfiguredHost, buildUserInput, - buildPromptJsonMessage, - getJsonSchemaFormat, requestStructuredOutput, - runWithMode, - buildRequest, formatIssues, compactErrorMessage } diff --git a/src/provider.js b/src/provider.js new file mode 100644 index 0000000..159eb62 --- /dev/null +++ b/src/provider.js @@ -0,0 +1,89 @@ +const SUPPORTED_PROVIDERS = [ + 'openai', + 'anthropic', + 'google', + 'mistral', + 'openai-compatible' +]; + +/** + * Create an AI SDK provider instance based on the provider type. + * + * @param {{ provider: string, apiKey: string, baseURL?: string }} opts + * @returns {import('ai').Provider} AI SDK provider instance + */ +function createProvider({ provider, apiKey, baseURL }) { + const type = String(provider || 'openai').trim().toLowerCase(); + + switch (type) { + case 'openai': { + const { createOpenAI } = require('@ai-sdk/openai'); + return createOpenAI({ + apiKey, + ...(baseURL ? { baseURL } : {}) + }); + } + + case 'anthropic': { + const { createAnthropic } = require('@ai-sdk/anthropic'); + return createAnthropic({ + apiKey, + ...(baseURL ? { baseURL } : {}) + }); + } + + case 'google': { + const { createGoogleGenerativeAI } = require('@ai-sdk/google'); + return createGoogleGenerativeAI({ + apiKey, + ...(baseURL ? { baseURL } : {}) + }); + } + + case 'mistral': { + const { createMistral } = require('@ai-sdk/mistral'); + return createMistral({ + apiKey, + ...(baseURL ? { baseURL } : {}) + }); + } + + case 'openai-compatible': { + const { createOpenAICompatible } = require('@ai-sdk/openai-compatible'); + if (!baseURL) { + throw new Error('openai-compatible provider requires a base URL (api_base).'); + } + return createOpenAICompatible({ + name: 'custom', + apiKey, + baseURL + }); + } + + default: + throw new Error( + `Unsupported AI provider: "${type}". ` + + `Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}` + ); + } +} + +/** + * Create a model instance from a provider and model name. + * + * @param {import('ai').Provider} provider AI SDK provider instance + * @param {string} modelName Model identifier (e.g. 'gpt-4o', 'claude-sonnet-4-20250514') + * @returns {import('ai').LanguageModel} + */ +function createModel(provider, modelName) { + if (!modelName) { + throw new Error('Model name is required.'); + } + return provider(modelName); +} + +module.exports = { + SUPPORTED_PROVIDERS, + createProvider, + createModel +}; diff --git a/test/config.test.js b/test/config.test.js index e326a35..086c729 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -10,6 +10,8 @@ function loadConfigWithMockedInputs(inputs, env = {}) { process.env.OPENAI_API_KEY = env.OPENAI_API_KEY || ''; process.env.OPENAI_API_BASE = env.OPENAI_API_BASE || ''; + const warnings = []; + Module._load = function patchedLoad(request, parent, isMain) { if (request === '@actions/core') { return { @@ -19,6 +21,9 @@ function loadConfigWithMockedInputs(inputs, env = {}) { throw new Error(`Input required and not supplied: ${name}`); } return value; + }, + warning(msg) { + warnings.push(msg); } }; } @@ -27,11 +32,15 @@ function loadConfigWithMockedInputs(inputs, env = {}) { try { delete require.cache[require.resolve('../src/config')]; + delete require.cache[require.resolve('../src/provider')]; const { loadConfig } = require('../src/config'); - return loadConfig(); + const config = loadConfig(); + config._warnings = warnings; + return config; } finally { Module._load = originalLoad; delete require.cache[require.resolve('../src/config')]; + delete require.cache[require.resolve('../src/provider')]; process.env.OPENAI_API_KEY = originalApiKey; process.env.OPENAI_API_BASE = originalApiBase; } @@ -48,22 +57,21 @@ test('loadConfig applies defaults for confidence and coverage-first mode', () => assert.equal(config.fallbackConfidenceValue, 0.5); assert.equal(config.coverageFirstRoundPrimaryOnly, true); assert.equal(config.autoMinimizeOutdatedComments, true); - assert.equal(config.llmCompatibilityMode, 'auto'); - assert.deepEqual(config.openaiApiBaseAllowlist, ['api.openai.com']); + assert.equal(config.aiProvider, 'openai'); + assert.deepEqual(config.apiBaseAllowlist, ['api.openai.com']); }); test('loadConfig parses custom confidence and coverage-first mode', () => { const config = loadConfigWithMockedInputs({ github_token: 'ghs_xxx', - openai_api_key: 'sk-test', + api_key: 'sk-test', min_finding_confidence: '0.85', missing_confidence_policy: 'fallback', fallback_confidence_value: '0.65', coverage_first_round_primary_only: 'false', auto_minimize_outdated_comments: 'false', - llm_compatibility_mode: 'chat_json_object', - openai_api_base: 'https://gateway.example.com/v1', - openai_api_base_allowlist: 'api.openai.com, gateway.example.com' + api_base: 'https://gateway.example.com/v1', + api_base_allowlist: 'api.openai.com, gateway.example.com' }); assert.equal(config.minFindingConfidence, 0.85); @@ -71,19 +79,70 @@ test('loadConfig parses custom confidence and coverage-first mode', () => { assert.equal(config.fallbackConfidenceValue, 0.65); assert.equal(config.coverageFirstRoundPrimaryOnly, false); assert.equal(config.autoMinimizeOutdatedComments, false); - assert.equal(config.llmCompatibilityMode, 'chat_json_object'); - assert.equal(config.openaiApiBase, 'https://gateway.example.com/v1'); - assert.deepEqual(config.openaiApiBaseAllowlist, ['api.openai.com', 'gateway.example.com']); + assert.equal(config.apiBase, 'https://gateway.example.com/v1'); + assert.deepEqual(config.apiBaseAllowlist, ['api.openai.com', 'gateway.example.com']); }); -test('loadConfig rejects invalid llm_compatibility_mode', () => { +test('loadConfig accepts ai_provider=anthropic', () => { + const config = loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + api_key: 'sk-ant-test', + ai_provider: 'anthropic' + }); + + assert.equal(config.aiProvider, 'anthropic'); + assert.equal(config.apiKey, 'sk-ant-test'); +}); + +test('loadConfig falls back from openai_api_key to api_key', () => { + const config = loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + openai_api_key: 'sk-legacy' + }); + + assert.equal(config.apiKey, 'sk-legacy'); + assert.equal(config.aiProvider, 'openai'); +}); + +test('loadConfig falls back from openai_api_base to api_base', () => { + const config = loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + api_key: 'sk-test', + openai_api_base: 'https://api.openai.com/v1', + openai_api_base_allowlist: 'api.openai.com' + }); + + assert.equal(config.apiBase, 'https://api.openai.com/v1'); +}); + +test('loadConfig warns when llm_compatibility_mode is non-auto', () => { + const config = loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + api_key: 'sk-test', + llm_compatibility_mode: 'chat_json_object' + }); + + assert.ok(config._warnings.some((w) => w.includes('deprecated'))); +}); + +test('loadConfig does not warn when llm_compatibility_mode is auto', () => { + const config = loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + api_key: 'sk-test', + llm_compatibility_mode: 'auto' + }); + + assert.equal(config._warnings.filter((w) => w.includes('deprecated')).length, 0); +}); + +test('loadConfig rejects unsupported ai_provider', () => { assert.throws( () => loadConfigWithMockedInputs({ github_token: 'ghs_xxx', - openai_api_key: 'sk-test', - llm_compatibility_mode: 'legacy' + api_key: 'sk-test', + ai_provider: 'deepseek' }), - /llm_compatibility_mode must be one of/ + /ai_provider must be one of/ ); }); @@ -167,26 +226,35 @@ test('loadConfig normalizes and deduplicates review_dimensions while preserving assert.deepEqual(config.reviewDimensions, ['general', 'security', 'testing']); }); -test('loadConfig rejects non-https openai_api_base', () => { +test('loadConfig rejects non-https api_base', () => { assert.throws( () => loadConfigWithMockedInputs({ github_token: 'ghs_xxx', - openai_api_key: 'sk-test', - openai_api_base: 'http://gateway.example.com/v1', + api_key: 'sk-test', + api_base: 'http://gateway.example.com/v1', openai_api_base_allowlist: 'gateway.example.com' }), /must use https scheme/ ); }); -test('loadConfig rejects openai_api_base host not in allowlist', () => { +test('loadConfig rejects api_base host not in allowlist', () => { assert.throws( () => loadConfigWithMockedInputs({ github_token: 'ghs_xxx', - openai_api_key: 'sk-test', - openai_api_base: 'https://gateway.example.com/v1', + api_key: 'sk-test', + api_base: 'https://gateway.example.com/v1', openai_api_base_allowlist: 'api.openai.com' }), /host is not in allowlist/ ); }); + +test('loadConfig requires api key from any source', () => { + assert.throws( + () => loadConfigWithMockedInputs({ + github_token: 'ghs_xxx' + }), + /Missing API key/ + ); +}); diff --git a/test/model-runtime.test.js b/test/model-runtime.test.js index 69fa613..14a0862 100644 --- a/test/model-runtime.test.js +++ b/test/model-runtime.test.js @@ -2,16 +2,16 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const Module = require('node:module'); const { z } = require('zod'); -const { zodTextFormat } = require('openai/helpers/zod'); -function loadRuntimeWithMockedOpenAI(clientFactory) { +function loadRuntimeWithMockedAI(generateTextMock) { const originalLoad = Module._load; Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'openai') { - return class FakeOpenAI { - constructor() { - return clientFactory(); + if (request === 'ai') { + return { + generateText: generateTextMock, + Output: { + object: ({ schema }) => ({ type: 'object', schema }) } }; } @@ -37,152 +37,167 @@ function createAgent() { }; } -test('runStructuredWithRepair falls back to chat_json_object and caches the successful mode', async () => { - let responseCalls = 0; - let chatCalls = 0; - const runtime = loadRuntimeWithMockedOpenAI(() => ({ - responses: { - create: async () => { - responseCalls += 1; - throw new Error('text.format is not supported by this provider'); - } - }, - chat: { - completions: { - create: async (requestData) => { - chatCalls += 1; - if (requestData.response_format?.type === 'json_schema') { - throw new Error('response_format json_schema not supported'); - } - return { - choices: [ - { - message: { - content: '{"overall":"ok"}' - } - } - ] - }; - } - } - } +function createFakeModel() { + return { modelId: 'test-model', provider: 'test' }; +} + +test('runStructuredWithRepair returns parsed output on success', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({ + output: { overall: 'looks good' }, + text: '{"overall":"looks good"}' })); - runtime.configureOpenAIClient({ apiKey: 'sk-test', compatibilityMode: 'auto' }); + runtime.configureRuntime({ model: createFakeModel() }); const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review this code'); - const first = await runtime.runStructuredWithRepair(agent, 'input', { allowRepair: true }); - const second = await runtime.runStructuredWithRepair(agent, 'input', { allowRepair: true }); - - assert.equal(first.ok, true); - assert.equal(first.mode, 'chat_json_object'); - assert.equal(first.calls, 3); - assert.equal(second.ok, true); - assert.equal(second.mode, 'chat_json_object'); - assert.equal(second.calls, 1); - assert.equal(responseCalls, 1); - assert.equal(chatCalls, 3); + assert.equal(result.ok, true); + assert.deepEqual(result.output, { overall: 'looks good' }); + assert.equal(result.calls, 1); + assert.equal(result.repaired, false); }); -test('runStructuredWithRepair repairs invalid json in the same mode before failing over', async () => { - const requests = []; - let attempts = 0; - const runtime = loadRuntimeWithMockedOpenAI(() => ({ - responses: { - create: async () => { - throw new Error('text.format is not supported by this provider'); - } - }, - chat: { - completions: { - create: async (requestData) => { - requests.push(requestData); - attempts += 1; - if (requestData.response_format?.type === 'json_schema') { - throw new Error('response_format json_schema not supported'); - } - if (attempts === 2) { - return { - choices: [ - { - message: { - content: 'not-json' - } - } - ] - }; - } - return { - choices: [ - { - message: { - content: '{"overall":"repaired"}' - } - } - ] - }; - } - } - } +test('runStructuredWithRepair falls back to text parsing when output is null', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({ + output: null, + text: '{"overall":"from text"}' })); - runtime.configureOpenAIClient({ apiKey: 'sk-test', compatibilityMode: 'auto' }); + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review'); - const result = await runtime.runStructuredWithRepair(createAgent(), 'original-input', { allowRepair: true }); + assert.equal(result.ok, true); + assert.deepEqual(result.output, { overall: 'from text' }); + assert.equal(result.calls, 1); +}); + +test('runStructuredWithRepair triggers repair on first failure and succeeds', async () => { + let callCount = 0; + const runtime = loadRuntimeWithMockedAI(async () => { + callCount += 1; + if (callCount === 1) { + return { output: null, text: 'not json' }; + } + return { output: { overall: 'repaired' }, text: '{"overall":"repaired"}' }; + }); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review'); assert.equal(result.ok, true); - assert.equal(result.mode, 'chat_json_object'); - assert.equal(result.calls, 4); + assert.deepEqual(result.output, { overall: 'repaired' }); + assert.equal(result.calls, 2); assert.equal(result.repaired, true); - assert.equal(requests[2].messages[1].content.includes('Validation error:'), true); - assert.equal(requests[2].messages[1].content.includes('Previous output preview:'), true); }); -test('prompt_json mode inlines system instructions into the user message', async () => { - const captured = []; - const runtime = loadRuntimeWithMockedOpenAI(() => ({ - responses: { create: async () => ({}) }, - chat: { - completions: { - create: async (requestData) => { - captured.push(requestData); - return { - choices: [ - { - message: { - content: '{"overall":"ok"}' - } - } - ] - }; - } - } - } +test('runStructuredWithRepair returns error when both attempts fail', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({ + output: null, + text: 'garbage' + })); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review'); + + assert.equal(result.ok, false); + assert.ok(result.error); + assert.equal(result.calls, 2); + assert.equal(result.repaired, true); +}); + +test('runStructuredWithRepair skips repair when allowRepair=false', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({ + output: null, + text: 'garbage' + })); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review', { allowRepair: false }); + + assert.equal(result.ok, false); + assert.equal(result.calls, 1); + assert.equal(result.repaired, false); +}); + +test('runStructuredWithRepair handles empty output text', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({ + output: null, + text: '' })); - runtime.configureOpenAIClient({ apiKey: 'sk-test', compatibilityMode: 'prompt_json', baseURL: 'https://gateway.example.com/v1' }); - const result = await runtime.runStructuredWithRepair(createAgent(), 'review this diff', { allowRepair: true }); + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review', { allowRepair: false }); + + assert.equal(result.ok, false); + assert.ok(result.error.message.includes('empty output')); +}); + +test('runStructuredWithRepair handles generateText throwing', async () => { + let callCount = 0; + const runtime = loadRuntimeWithMockedAI(async () => { + callCount += 1; + if (callCount === 1) { + throw new Error('API rate limit exceeded'); + } + return { output: { overall: 'recovered' }, text: '{"overall":"recovered"}' }; + }); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review'); assert.equal(result.ok, true); - assert.equal(captured.length, 1); - assert.equal(captured[0].messages.length, 1); - assert.equal(captured[0].messages[0].role, 'user'); - assert.match(captured[0].messages[0].content, /Follow these task instructions exactly/); - assert.match(captured[0].messages[0].content, /System instructions/); - assert.match(captured[0].messages[0].content, /JSON contract here/); + assert.deepEqual(result.output, { overall: 'recovered' }); + assert.equal(result.repaired, true); +}); + +test('configureRuntime throws when model is falsy', () => { + const runtime = loadRuntimeWithMockedAI(async () => ({})); + assert.throws(() => runtime.configureRuntime({ model: null }), /valid AI SDK model/); }); -test('sanitizeJsonSchema rewrites integer exclusiveMinimum for claude-compatible providers', () => { - const runtime = loadRuntimeWithMockedOpenAI(() => ({ responses: {}, chat: { completions: {} } })); - const schema = z.object({ - line: z.number().int().positive().nullable().default(null) +test('runStructuredWithRepair throws when runtime not configured', async () => { + const runtime = loadRuntimeWithMockedAI(async () => ({})); + const agent = createAgent(); + await assert.rejects( + () => runtime.runStructuredWithRepair(agent, 'review'), + /not configured/ + ); +}); + +test('extractJsonObjectText extracts JSON from code fences', () => { + const runtime = loadRuntimeWithMockedAI(async () => ({})); + const input = '```json\n{"overall":"test"}\n```'; + assert.equal(runtime.extractJsonObjectText(input), '{"overall":"test"}'); +}); + +test('extractJsonObjectText extracts JSON from mixed text', () => { + const runtime = loadRuntimeWithMockedAI(async () => ({})); + const input = 'Here is the result: {"overall":"test"} done.'; + assert.equal(runtime.extractJsonObjectText(input), '{"overall":"test"}'); +}); + +test('schema validation failure on first attempt triggers repair', async () => { + let callCount = 0; + const runtime = loadRuntimeWithMockedAI(async () => { + callCount += 1; + if (callCount === 1) { + // Returns valid JSON but fails schema validation (missing required field) + return { output: null, text: '{"wrong_field":"value"}' }; + } + return { output: { overall: 'valid' }, text: '{"overall":"valid"}' }; }); - const raw = zodTextFormat(schema, 'test_output'); - const sanitized = runtime.sanitizeJsonSchema(raw.schema); - - const integerNode = sanitized.properties.line.anyOf.find((item) => item.type === 'integer'); - assert.equal(integerNode.minimum, 1); - assert.equal('exclusiveMinimum' in integerNode, false); - assert.equal('$schema' in sanitized, false); - assert.equal('default' in sanitized.properties.line, false); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = createAgent(); + const result = await runtime.runStructuredWithRepair(agent, 'review'); + + assert.equal(result.ok, true); + assert.deepEqual(result.output, { overall: 'valid' }); + assert.equal(result.repaired, true); }); diff --git a/test/provider.test.js b/test/provider.test.js new file mode 100644 index 0000000..6baf42a --- /dev/null +++ b/test/provider.test.js @@ -0,0 +1,77 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { SUPPORTED_PROVIDERS, createProvider, createModel } = require('../src/provider'); + +test('SUPPORTED_PROVIDERS includes all expected providers', () => { + assert.deepEqual(SUPPORTED_PROVIDERS, [ + 'openai', + 'anthropic', + 'google', + 'mistral', + 'openai-compatible' + ]); +}); + +test('createProvider creates an openai provider instance', () => { + const provider = createProvider({ provider: 'openai', apiKey: 'sk-test' }); + assert.equal(typeof provider, 'function'); +}); + +test('createProvider creates an anthropic provider instance', () => { + const provider = createProvider({ provider: 'anthropic', apiKey: 'sk-ant-test' }); + assert.equal(typeof provider, 'function'); +}); + +test('createProvider creates a google provider instance', () => { + const provider = createProvider({ provider: 'google', apiKey: 'goog-test' }); + assert.equal(typeof provider, 'function'); +}); + +test('createProvider creates a mistral provider instance', () => { + const provider = createProvider({ provider: 'mistral', apiKey: 'mist-test' }); + assert.equal(typeof provider, 'function'); +}); + +test('createProvider creates an openai-compatible provider with baseURL', () => { + const provider = createProvider({ + provider: 'openai-compatible', + apiKey: 'sk-test', + baseURL: 'https://api.groq.com/openai/v1' + }); + assert.equal(typeof provider, 'function'); +}); + +test('createProvider throws when openai-compatible has no baseURL', () => { + assert.throws( + () => createProvider({ provider: 'openai-compatible', apiKey: 'sk-test' }), + /requires a base URL/ + ); +}); + +test('createProvider throws for unsupported provider', () => { + assert.throws( + () => createProvider({ provider: 'deepseek', apiKey: 'sk-test' }), + /Unsupported AI provider/ + ); +}); + +test('createProvider defaults to openai when provider is empty', () => { + const provider = createProvider({ provider: '', apiKey: 'sk-test' }); + assert.equal(typeof provider, 'function'); +}); + +test('createModel returns a model object', () => { + const provider = createProvider({ provider: 'openai', apiKey: 'sk-test' }); + const model = createModel(provider, 'gpt-4o'); + assert.ok(model); + assert.equal(typeof model, 'object'); +}); + +test('createModel throws when modelName is empty', () => { + const provider = createProvider({ provider: 'openai', apiKey: 'sk-test' }); + assert.throws( + () => createModel(provider, ''), + /Model name is required/ + ); +}); From e4491df2ed5f70ec5369f495c525017ac4b47b22 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sun, 19 Apr 2026 13:53:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(action):=20=F0=9F=90=9B=20Fix=20reviewe?= =?UTF-8?q?r=5Fmodel=20ignored,=20empty=20allowlist=20bypass,=20and=20prov?= =?UTF-8?q?ider=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Support per-agent model override via agent.modelInstance in requestStructuredOutput, falling back to runtimeState.model - Create separate reviewerModelInstance in index.js and inject into reviewer agents so reviewer_model config takes effect - Reject all hosts when allowlist resolves to empty set instead of silently skipping validation (security regression fix) - Add AI_PROVIDER validation against SUPPORTED_PROVIDERS in verify-schema-support.js for early error reporting --- scripts/verify-schema-support.js | 8 +++++++- src/agents.js | 3 ++- src/config.js | 10 ++++++++-- src/index.js | 4 ++++ src/model-runtime.js | 3 ++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/scripts/verify-schema-support.js b/scripts/verify-schema-support.js index d3557a5..1b7ae06 100644 --- a/scripts/verify-schema-support.js +++ b/scripts/verify-schema-support.js @@ -9,7 +9,7 @@ const { createReviewerAgent, runStructuredWithRepair } = require('../src/agents'); -const { createProvider, createModel } = require('../src/provider'); +const { createProvider, createModel, SUPPORTED_PROVIDERS } = require('../src/provider'); function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) { @@ -104,6 +104,12 @@ async function main() { const apiKey = process.env.OPENAI_API_KEY || process.env.API_KEY || ''; const baseURL = process.env.OPENAI_API_BASE || process.env.API_BASE || ''; const providerType = process.env.AI_PROVIDER || 'openai'; + if (!SUPPORTED_PROVIDERS.includes(providerType)) { + throw new Error( + `Unsupported AI_PROVIDER: "${providerType}". ` + + `Supported: ${SUPPORTED_PROVIDERS.join(', ')}` + ); + } const modelInput = process.env.MODEL || process.env.OPENAI_MODEL || ''; const bugProbeRequired = parseBooleanEnv('BUG_PROBE_REQUIRED', false); diff --git a/src/agents.js b/src/agents.js index 28baf2d..fe4fba1 100644 --- a/src/agents.js +++ b/src/agents.js @@ -127,7 +127,7 @@ Output must follow the required JSON contract exactly.`; }; } -function createReviewerAgent({ dimension, model, language, projectGuidance }) { +function createReviewerAgent({ dimension, model, modelInstance, language, projectGuidance }) { const dimensionPrompt = { general: 'Focus on correctness, maintainability, edge cases, and regressions.', security: 'Focus on vulnerabilities, authn/authz, injection, secrets, unsafe deserialization, SSRF, path traversal, and supply chain risk.', @@ -162,6 +162,7 @@ Output must follow the required JSON contract exactly.`; return { name: `${dimension} reviewer`, model, + modelInstance: modelInstance || null, instructions, schema: reviewOutputSchema, responseName: `${dimension}_review_output`, diff --git a/src/config.js b/src/config.js index 3c59878..ae731cf 100644 --- a/src/config.js +++ b/src/config.js @@ -35,9 +35,15 @@ function validateBaseURL(baseURL, allowedHosts) { throw new Error('Input api_base must not contain username/password credentials.'); } - if (allowedHosts && allowedHosts.length > 0) { - const host = normalizeHost(parsed.hostname); + if (Array.isArray(allowedHosts)) { const allow = new Set(allowedHosts.map(normalizeHost).filter(Boolean)); + if (allow.size === 0) { + throw new Error( + 'Input api_base is set but allowlist is empty — all hosts are blocked. ' + + 'Set api_base_allowlist (or openai_api_base_allowlist) to explicitly trust the target host.' + ); + } + const host = normalizeHost(parsed.hostname); if (!allow.has(host)) { throw new Error( `Input api_base host is not in allowlist: ${host}. ` + diff --git a/src/index.js b/src/index.js index 2bc8214..a37bc67 100644 --- a/src/index.js +++ b/src/index.js @@ -497,6 +497,9 @@ async function runAction() { baseURL: config.apiBase || undefined }); const plannerModelInstance = createModel(provider, config.plannerModel); + const reviewerModelInstance = config.reviewerModel !== config.plannerModel + ? createModel(provider, config.reviewerModel) + : plannerModelInstance; configureRuntime({ model: plannerModelInstance }); core.info(`AI provider: ${config.aiProvider}`); @@ -509,6 +512,7 @@ async function runAction() { reviewerAgents[dimension] = createReviewerAgent({ dimension, model: config.reviewerModel, + modelInstance: reviewerModelInstance, language: config.reviewLanguage, projectGuidance }); diff --git a/src/model-runtime.js b/src/model-runtime.js index 0eec285..92da5ac 100644 --- a/src/model-runtime.js +++ b/src/model-runtime.js @@ -85,9 +85,10 @@ async function runStructuredWithRepair(agent, input, options = {}) { async function requestStructuredOutput({ agent, input, repairContext }) { const userPrompt = buildUserInput(agent, input, repairContext); + const model = agent.modelInstance || runtimeState.model; const result = await generateText({ - model: runtimeState.model, + model, system: agent.instructions, prompt: userPrompt, output: Output.object({ schema: agent.schema }) From 65f2e165835a826a35653dcfe35ce568d73b7664 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sun, 19 Apr 2026 13:53:20 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(action):=20=E2=9C=85=20Add=20tests=20f?= =?UTF-8?q?or=20agent=20model=20override=20and=20empty=20allowlist=20rejec?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test requestStructuredOutput uses agent.modelInstance when set - Test requestStructuredOutput falls back to runtime model when null - Test loadConfig rejects api_base with empty-after-normalization allowlist --- test/config.test.js | 13 +++++++++++++ test/model-runtime.test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/test/config.test.js b/test/config.test.js index 086c729..e1553df 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -258,3 +258,16 @@ test('loadConfig requires api key from any source', () => { /Missing API key/ ); }); + +test('loadConfig rejects api_base when allowlist resolves to empty after normalization', () => { + assert.throws( + () => loadConfigWithMockedInputs({ + github_token: 'ghs_xxx', + api_key: 'sk-test', + api_base: 'https://gateway.example.com/v1', + api_base_allowlist: ' , ', + openai_api_base_allowlist: '' + }), + /allowlist is empty/ + ); +}); diff --git a/test/model-runtime.test.js b/test/model-runtime.test.js index 14a0862..2b8667c 100644 --- a/test/model-runtime.test.js +++ b/test/model-runtime.test.js @@ -182,6 +182,38 @@ test('extractJsonObjectText extracts JSON from mixed text', () => { assert.equal(runtime.extractJsonObjectText(input), '{"overall":"test"}'); }); +test('requestStructuredOutput uses agent.modelInstance when provided', async () => { + const customModel = { modelId: 'custom-reviewer', provider: 'test' }; + let usedModel = null; + const runtime = loadRuntimeWithMockedAI(async (opts) => { + usedModel = opts.model; + return { output: { overall: 'from custom' }, text: '{}' }; + }); + + runtime.configureRuntime({ model: createFakeModel() }); + const agent = { ...createAgent(), modelInstance: customModel }; + const result = await runtime.runStructuredWithRepair(agent, 'review'); + + assert.equal(result.ok, true); + assert.equal(usedModel, customModel); +}); + +test('requestStructuredOutput falls back to runtimeState.model when agent.modelInstance is null', async () => { + const defaultModel = createFakeModel(); + let usedModel = null; + const runtime = loadRuntimeWithMockedAI(async (opts) => { + usedModel = opts.model; + return { output: { overall: 'from default' }, text: '{}' }; + }); + + runtime.configureRuntime({ model: defaultModel }); + const agent = { ...createAgent(), modelInstance: null }; + const result = await runtime.runStructuredWithRepair(agent, 'review'); + + assert.equal(result.ok, true); + assert.equal(usedModel, defaultModel); +}); + test('schema validation failure on first attempt triggers repair', async () => { let callCount = 0; const runtime = loadRuntimeWithMockedAI(async () => {