diff --git a/docs/exec-plans/active/provider-resolver-refactor.md b/docs/exec-plans/active/provider-resolver-refactor.md new file mode 100644 index 0000000..4323637 --- /dev/null +++ b/docs/exec-plans/active/provider-resolver-refactor.md @@ -0,0 +1,56 @@ +# Provider Resolver Refactor + +**Status:** Complete (Phase 1–5) +**Scope:** Provider system unification — resolve "主聊天能用、旁路功能失效" structural issue + +## Problem + +- `ApiProvider` mixes protocol/vendor/auth/model-mapping into one flat structure +- `claude-client` and `text-generator` are two independent provider resolution chains +- GLM/Kimi/MiniMax/Volcengine/Bailian are Anthropic-compatible but built as `custom` (OpenAI-compatible in text-generator) +- Model listing relies on hardcoded base_url → model label mapping +- Bridge/chat/onboarding/checkin/plan have divergent provider resolution +- No contract tests + +## Solution + +### Phase 1: Core modules +- `src/lib/provider-catalog.ts` — vendor presets, default model catalogs, protocol definitions +- `src/lib/provider-resolver.ts` — unified provider/model resolution for all consumers + +### Phase 2: DB migration +- Add `protocol`, `headers_json`, `env_overrides_json`, `role_models_json` to `api_providers` +- Create `provider_models` table +- Migrate existing data (provider_type → protocol, extra_env → env_overrides_json) + +### Phase 3: Consumer refactoring +- `claude-client.ts` → use resolver's `toClaudeCodeEnv()` +- `text-generator.ts` → use resolver's `toAiSdkConfig()` +- `route.ts`, `conversation-engine.ts`, `onboarding`, `checkin`, `plan` → use shared resolver + +### Phase 4: UI update +- `ProviderManager.tsx` presets → reference catalog +- `ProviderForm.tsx` → protocol selector instead of provider_type +- `/api/providers/models` → read from catalog + provider_models + +### Phase 5: Tests +- Provider resolver contract tests +- Entry point consistency tests +- Preset protocol validation +- Regression tests (env cleanup, bind semantics) + +## Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2026-03-09 | Add fields to existing table, not new system | Minimize migration risk, preserve existing data | +| 2026-03-09 | Protocol-based dispatch instead of brand-name | Matches actual API behavior, extensible | +| 2026-03-09 | Merge custom-anthropic into anthropic-thirdparty | Remove user confusion between two nearly identical presets | +| 2026-03-09 | Auth env keys protected from envOverrides deletion | Legacy extra_env placeholders (`{"ANTHROPIC_AUTH_TOKEN":""}`) were deleting freshly-injected credentials | +| 2026-03-09 | Env var names use `_MODEL` suffix | Claude Code SDK expects `ANTHROPIC_DEFAULT_SONNET_MODEL`, not `ANTHROPIC_DEFAULT_SONNET` | + +## Verification + +- **aiberm** (no model mapping): ✅ responded "OK claude-sonnet-4-6" +- **pipellm** (with sonnet/opus/haiku mapping): ✅ responded "OK claude-sonnet-4-6" +- Unit tests: 229/229 passed diff --git a/package-lock.json b/package-lock.json index 45a59d0..ec49a94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "codepilot", "version": "0.29.0", "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.77", "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", + "@ai-sdk/google-vertex": "^4.0.80", "@ai-sdk/openai": "^3.0.34", "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@google/genai": "^1.43.0", @@ -78,14 +80,63 @@ "wait-on": "^9.0.3" } }, - "node_modules/@ai-sdk/anthropic": { - "version": "3.0.47", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.47.tgz", - "integrity": "sha512-E6Z3i/xvxGDxRskMMbuX9+xDK4l5LesrP2O7YQ0CcbAkYP25qTo/kYGf/AsJrLkNIY23HeO/kheUWtG1XZllDA==", + "node_modules/@ai-sdk/amazon-bedrock": { + "version": "4.0.77", + "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.77.tgz", + "integrity": "sha512-PKLqycbq4NHCasZ0454HZh58thDGsqEKnpkXYr+Ym4Y4YFL3vhLUwhvvZeuOfawVdmMijQfRiKeAZhd2XtTkOg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "3.0.58", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@smithy/eventstream-codec": "^4.0.1", + "@smithy/util-utf8": "^4.0.0", + "aws4fetch": "^1.0.20" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/amazon-bedrock/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/amazon-bedrock/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.58", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz", + "integrity": "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" }, "engines": { "node": ">=18" @@ -107,9 +158,9 @@ } }, "node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", - "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", @@ -141,13 +192,61 @@ } }, "node_modules/@ai-sdk/google": { - "version": "3.0.32", - "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.32.tgz", - "integrity": "sha512-BK8UOfLkGdoQQ0YgCuLm8uA+ZTPYRjKP2MAy6FPdZrx+DSc0Qvkl6vJWDjpS2tApSBp4xYkCyddaPapdE7o7/Q==", + "version": "3.0.43", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz", + "integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google-vertex": { + "version": "4.0.80", + "resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.80.tgz", + "integrity": "sha512-CZbiI3AEZQjVYUI2duLFH3ONYkUbsoi0AiTEYNaNKC+ZVnteXwmzJB0W7miaDViV66pWh2VpjNgIJnYXCtNgfw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/anthropic": "3.0.58", + "@ai-sdk/google": "3.0.43", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "google-auth-library": "^10.5.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google-vertex/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/google-vertex/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", + "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" @@ -169,9 +268,9 @@ } }, "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", - "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", @@ -407,6 +506,82 @@ "zod": "^4.0.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -6324,6 +6499,83 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.11.tgz", + "integrity": "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.0", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@splinetool/runtime": { "version": "0.9.526", "resolved": "https://registry.npmjs.org/@splinetool/runtime/-/runtime-0.9.526.tgz", @@ -8694,6 +8946,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, "node_modules/axe-core": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", diff --git a/package.json b/package.json index cb16b22..bbb8e28 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,10 @@ ] }, "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.77", "@ai-sdk/anthropic": "^3.0.47", "@ai-sdk/google": "^3.0.31", + "@ai-sdk/google-vertex": "^4.0.80", "@ai-sdk/openai": "^3.0.34", "@anthropic-ai/claude-agent-sdk": "^0.2.62", "@google/genai": "^1.43.0", diff --git a/src/__tests__/unit/provider-resolver.test.ts b/src/__tests__/unit/provider-resolver.test.ts new file mode 100644 index 0000000..a55354e --- /dev/null +++ b/src/__tests__/unit/provider-resolver.test.ts @@ -0,0 +1,910 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + VENDOR_PRESETS, + inferProtocolFromLegacy, + inferAuthStyleFromLegacy, + getDefaultModelsForProvider, + findPresetForLegacy, +} from '../../lib/provider-catalog'; +import type { Protocol } from '../../lib/provider-catalog'; + +// ── Provider Catalog Tests ────────────────────────────────────── + +describe('Provider Catalog', () => { + describe('VENDOR_PRESETS', () => { + it('all presets have required fields', () => { + for (const preset of VENDOR_PRESETS) { + assert.ok(preset.key, `Preset missing key`); + assert.ok(preset.name, `Preset ${preset.key} missing name`); + assert.ok(preset.protocol, `Preset ${preset.key} missing protocol`); + assert.ok(preset.description, `Preset ${preset.key} missing description`); + assert.ok(preset.descriptionZh, `Preset ${preset.key} missing descriptionZh`); + assert.ok(preset.authStyle, `Preset ${preset.key} missing authStyle`); + assert.ok(Array.isArray(preset.fields), `Preset ${preset.key} fields not array`); + assert.ok(preset.iconKey, `Preset ${preset.key} missing iconKey`); + } + }); + + it('preset keys are unique', () => { + const keys = VENDOR_PRESETS.map(p => p.key); + const unique = new Set(keys); + assert.equal(keys.length, unique.size, `Duplicate preset keys found`); + }); + + it('GLM presets use anthropic protocol', () => { + const glmPresets = VENDOR_PRESETS.filter(p => p.key.startsWith('glm-')); + assert.ok(glmPresets.length >= 2, 'Expected at least 2 GLM presets'); + for (const p of glmPresets) { + assert.equal(p.protocol, 'anthropic', `GLM preset ${p.key} should use anthropic protocol`); + } + }); + + it('Kimi preset uses anthropic protocol', () => { + const kimi = VENDOR_PRESETS.find(p => p.key === 'kimi'); + assert.ok(kimi, 'Kimi preset not found'); + assert.equal(kimi.protocol, 'anthropic'); + assert.equal(kimi.authStyle, 'auth_token'); + }); + + it('MiniMax presets use anthropic protocol', () => { + const minimax = VENDOR_PRESETS.filter(p => p.key.startsWith('minimax-')); + assert.ok(minimax.length >= 2, 'Expected at least 2 MiniMax presets'); + for (const p of minimax) { + assert.equal(p.protocol, 'anthropic', `MiniMax preset ${p.key} should use anthropic protocol`); + } + }); + + it('Volcengine preset uses anthropic protocol with auth_token', () => { + const volc = VENDOR_PRESETS.find(p => p.key === 'volcengine'); + assert.ok(volc, 'Volcengine preset not found'); + assert.equal(volc.protocol, 'anthropic'); + assert.equal(volc.authStyle, 'auth_token'); + }); + + it('Bailian preset uses anthropic protocol', () => { + const bailian = VENDOR_PRESETS.find(p => p.key === 'bailian'); + assert.ok(bailian, 'Bailian preset not found'); + assert.equal(bailian.protocol, 'anthropic'); + }); + + it('Bedrock preset uses bedrock protocol with env_only auth', () => { + const bedrock = VENDOR_PRESETS.find(p => p.key === 'bedrock'); + assert.ok(bedrock, 'Bedrock preset not found'); + assert.equal(bedrock.protocol, 'bedrock'); + assert.equal(bedrock.authStyle, 'env_only'); + }); + + it('Vertex preset uses vertex protocol with env_only auth', () => { + const vertex = VENDOR_PRESETS.find(p => p.key === 'vertex'); + assert.ok(vertex, 'Vertex preset not found'); + assert.equal(vertex.protocol, 'vertex'); + assert.equal(vertex.authStyle, 'env_only'); + }); + + it('OpenRouter preset uses openrouter protocol', () => { + const or = VENDOR_PRESETS.find(p => p.key === 'openrouter'); + assert.ok(or, 'OpenRouter preset not found'); + assert.equal(or.protocol, 'openrouter'); + }); + + it('custom-openai preset uses openai-compatible protocol', () => { + const custom = VENDOR_PRESETS.find(p => p.key === 'custom-openai'); + assert.ok(custom, 'custom-openai preset not found'); + assert.equal(custom.protocol, 'openai-compatible'); + }); + + it('anthropic-thirdparty preset uses anthropic protocol and has env_overrides field', () => { + const preset = VENDOR_PRESETS.find(p => p.key === 'anthropic-thirdparty'); + assert.ok(preset, 'anthropic-thirdparty preset not found'); + assert.equal(preset.protocol, 'anthropic'); + assert.ok(preset.fields.includes('env_overrides'), 'should expose env_overrides field'); + }); + }); + + describe('inferProtocolFromLegacy', () => { + it('anthropic type → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('anthropic', 'https://api.anthropic.com'), 'anthropic'); + }); + + it('openrouter type → openrouter protocol', () => { + assert.equal(inferProtocolFromLegacy('openrouter', 'https://openrouter.ai/api'), 'openrouter'); + }); + + it('bedrock type → bedrock protocol', () => { + assert.equal(inferProtocolFromLegacy('bedrock', ''), 'bedrock'); + }); + + it('vertex type → vertex protocol', () => { + assert.equal(inferProtocolFromLegacy('vertex', ''), 'vertex'); + }); + + it('gemini-image type → gemini-image protocol', () => { + assert.equal(inferProtocolFromLegacy('gemini-image', 'https://generativelanguage.googleapis.com'), 'gemini-image'); + }); + + // Critical: Chinese vendors with custom type should infer anthropic + it('custom type + GLM base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://open.bigmodel.cn/api/anthropic'), 'anthropic'); + assert.equal(inferProtocolFromLegacy('custom', 'https://api.z.ai/api/anthropic'), 'anthropic'); + }); + + it('custom type + Kimi base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://api.kimi.com/coding/'), 'anthropic'); + }); + + it('custom type + Moonshot base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://api.moonshot.cn/anthropic'), 'anthropic'); + }); + + it('custom type + MiniMax base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://api.minimaxi.com/anthropic'), 'anthropic'); + assert.equal(inferProtocolFromLegacy('custom', 'https://api.minimax.io/anthropic'), 'anthropic'); + }); + + it('custom type + Volcengine base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://ark.cn-beijing.volces.com/api/coding'), 'anthropic'); + }); + + it('custom type + Bailian base_url → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://coding.dashscope.aliyuncs.com/apps/anthropic'), 'anthropic'); + }); + + it('custom type + unknown URL → openai-compatible protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://my-server.example.com/v1'), 'openai-compatible'); + }); + + it('custom type + URL containing /anthropic → anthropic protocol', () => { + assert.equal(inferProtocolFromLegacy('custom', 'https://proxy.example.com/anthropic'), 'anthropic'); + }); + }); + + describe('inferAuthStyleFromLegacy', () => { + it('bedrock → env_only', () => { + assert.equal(inferAuthStyleFromLegacy('bedrock', '{}'), 'env_only'); + }); + + it('vertex → env_only', () => { + assert.equal(inferAuthStyleFromLegacy('vertex', '{}'), 'env_only'); + }); + + it('extra_env with ANTHROPIC_AUTH_TOKEN → auth_token', () => { + assert.equal( + inferAuthStyleFromLegacy('custom', '{"ANTHROPIC_AUTH_TOKEN":""}'), + 'auth_token', + ); + }); + + it('extra_env with ANTHROPIC_API_KEY → api_key', () => { + assert.equal( + inferAuthStyleFromLegacy('custom', '{"ANTHROPIC_API_KEY":""}'), + 'api_key', + ); + }); + + it('empty extra_env → api_key', () => { + assert.equal(inferAuthStyleFromLegacy('anthropic', '{}'), 'api_key'); + }); + }); + + describe('getDefaultModelsForProvider', () => { + it('anthropic protocol with GLM CN url returns GLM models', () => { + const models = getDefaultModelsForProvider('anthropic', 'https://open.bigmodel.cn/api/anthropic'); + assert.ok(models.length > 0); + assert.ok(models.some(m => m.displayName.includes('GLM'))); + }); + + it('anthropic protocol with Bailian url returns Bailian models', () => { + const models = getDefaultModelsForProvider('anthropic', 'https://coding.dashscope.aliyuncs.com/apps/anthropic'); + assert.ok(models.length > 0); + assert.ok(models.some(m => m.displayName.includes('Qwen'))); + }); + + it('anthropic protocol with unknown url returns default Anthropic models', () => { + const models = getDefaultModelsForProvider('anthropic', 'https://my-proxy.com/api'); + assert.ok(models.length > 0); + assert.ok(models.some(m => m.modelId === 'sonnet')); + }); + + it('bedrock protocol returns default Anthropic models', () => { + const models = getDefaultModelsForProvider('bedrock', ''); + assert.ok(models.length > 0); + assert.ok(models.some(m => m.modelId === 'sonnet')); + }); + + it('openai-compatible protocol with no matching url returns empty', () => { + const models = getDefaultModelsForProvider('openai-compatible', 'https://example.com/v1'); + assert.equal(models.length, 0); + }); + }); + + describe('findPresetForLegacy', () => { + it('finds bedrock preset by type', () => { + const preset = findPresetForLegacy('', 'bedrock'); + assert.ok(preset); + assert.equal(preset.key, 'bedrock'); + }); + + it('finds GLM preset by base_url', () => { + const preset = findPresetForLegacy('https://open.bigmodel.cn/api/anthropic', 'custom'); + assert.ok(preset); + assert.equal(preset.key, 'glm-cn'); + }); + + it('finds Kimi preset by base_url', () => { + const preset = findPresetForLegacy('https://api.kimi.com/coding/', 'custom'); + assert.ok(preset); + assert.equal(preset.key, 'kimi'); + }); + + it('finds anthropic-official by base_url + type', () => { + const preset = findPresetForLegacy('https://api.anthropic.com', 'anthropic'); + assert.ok(preset); + assert.equal(preset.key, 'anthropic-official'); + }); + }); +}); + +// ── Provider Resolver Tests ───────────────────────────────────── + +import { resolveProvider, toClaudeCodeEnv, toAiSdkConfig } from '../../lib/provider-resolver'; +import type { ResolvedProvider } from '../../lib/provider-resolver'; + +describe('Provider Resolver', () => { + describe('resolveProvider', () => { + it('returns env-based resolution when providerId is "env"', () => { + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.provider, undefined); + assert.equal(resolved.protocol, 'anthropic'); + assert.deepEqual(resolved.settingSources, ['user', 'project', 'local']); + }); + + it('returns env-based resolution when no provider configured', () => { + // With no providers in DB, should return env-based + const resolved = resolveProvider({}); + // provider may be undefined or the default — depends on DB state + assert.equal(resolved.protocol, 'anthropic'); + }); + }); + + describe('toClaudeCodeEnv', () => { + it('injects ANTHROPIC_API_KEY for api_key auth style', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', + name: 'Test', + provider_type: 'anthropic', + protocol: 'anthropic', + base_url: 'https://api.anthropic.com', + api_key: 'sk-test-key', + is_active: 1, + sort_order: 0, + extra_env: '{}', + headers_json: '{}', + env_overrides_json: '', + role_models_json: '{}', + notes: '', + created_at: '', + updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + modelDisplayName: 'Sonnet 4.6', + upstreamModel: 'sonnet', + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const env = toClaudeCodeEnv({ PATH: '/usr/bin' }, resolved); + assert.equal(env.ANTHROPIC_API_KEY, 'sk-test-key'); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'sk-test-key'); + assert.equal(env.ANTHROPIC_BASE_URL, 'https://api.anthropic.com'); + }); + + it('injects only ANTHROPIC_AUTH_TOKEN for auth_token style', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', + name: 'Kimi', + provider_type: 'anthropic', + protocol: 'anthropic', + base_url: 'https://api.kimi.com/coding/', + api_key: 'kimi-key', + is_active: 1, + sort_order: 0, + extra_env: '{}', + headers_json: '{}', + env_overrides_json: '', + role_models_json: '{}', + notes: '', + created_at: '', + updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'auth_token', + model: 'sonnet', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const env = toClaudeCodeEnv({ PATH: '/usr/bin', ANTHROPIC_API_KEY: 'old-key' }, resolved); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, 'kimi-key'); + // auth_token style should NOT set ANTHROPIC_API_KEY + assert.equal(env.ANTHROPIC_API_KEY, undefined); + }); + + it('applies env overrides with empty-string deletion', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', + name: 'Test', + provider_type: 'anthropic', + protocol: 'anthropic', + base_url: '', + api_key: 'key', + is_active: 1, + sort_order: 0, + extra_env: '{}', + headers_json: '{}', + env_overrides_json: '', + role_models_json: '{}', + notes: '', + created_at: '', + updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: undefined, + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: { + API_TIMEOUT_MS: '3000000', + ANTHROPIC_API_KEY: '', // legacy placeholder — should be skipped (auth keys handled by auth injection) + SOME_CUSTOM_VAR: '', // non-auth key — should be deleted + }, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const env = toClaudeCodeEnv({ PATH: '/usr/bin', SOME_CUSTOM_VAR: 'old' }, resolved); + assert.equal(env.API_TIMEOUT_MS, '3000000'); + // Auth keys are NOT deleted by envOverrides — they're managed by the auth injection logic above + assert.equal(env.ANTHROPIC_API_KEY, 'key'); // preserved from auth injection + assert.equal(env.SOME_CUSTOM_VAR, undefined); // non-auth key deleted by empty string + }); + + it('injects role models as env vars', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', + name: 'Test', + provider_type: 'anthropic', + protocol: 'anthropic', + base_url: '', + api_key: 'key', + is_active: 1, + sort_order: 0, + extra_env: '{}', + headers_json: '{}', + env_overrides_json: '', + role_models_json: '{}', + notes: '', + created_at: '', + updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: undefined, + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: { + default: 'my-model-v1', + reasoning: 'my-reasoning-model', + small: 'my-small-model', + }, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const env = toClaudeCodeEnv({}, resolved); + assert.equal(env.ANTHROPIC_MODEL, 'my-model-v1'); + assert.equal(env.ANTHROPIC_REASONING_MODEL, 'my-reasoning-model'); + assert.equal(env.ANTHROPIC_SMALL_FAST_MODEL, 'my-small-model'); + }); + + it('preserves env vars when no provider (env-based)', () => { + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: undefined, + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: false, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + + const env = toClaudeCodeEnv({ + ANTHROPIC_API_KEY: 'shell-key', + PATH: '/usr/bin', + }, resolved); + assert.equal(env.ANTHROPIC_API_KEY, 'shell-key'); // preserved + assert.equal(env.PATH, '/usr/bin'); + }); + }); + + describe('toAiSdkConfig', () => { + it('anthropic protocol → anthropic SDK', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'Test', provider_type: 'anthropic', protocol: 'anthropic', + base_url: 'https://api.anthropic.com', api_key: 'key', is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + modelDisplayName: 'Sonnet 4.6', + upstreamModel: 'sonnet', + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.apiKey, 'key'); + assert.equal(config.baseUrl, 'https://api.anthropic.com'); + assert.equal(config.modelId, 'sonnet'); + assert.deepEqual(config.processEnvInjections, {}); + }); + + it('openrouter protocol → openai SDK with correct base URL', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'OR', provider_type: 'openrouter', protocol: 'openrouter', + base_url: 'https://openrouter.ai/api', api_key: 'or-key', is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'openrouter', + authStyle: 'api_key', + model: 'sonnet', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'openai'); + assert.equal(config.apiKey, 'or-key'); + assert.equal(config.baseUrl, 'https://openrouter.ai/api'); + }); + + it('bedrock protocol → injects env overrides', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'Bedrock', provider_type: 'bedrock', protocol: 'bedrock', + base_url: '', api_key: '', is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'bedrock', + authStyle: 'env_only', + model: 'sonnet', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-east-1', + }, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'bedrock'); // no base_url → native bedrock SDK + assert.deepEqual(config.processEnvInjections, { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-east-1', + }); + }); + + it('openai-compatible protocol → openai SDK', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'Custom', provider_type: 'custom', protocol: 'openai-compatible', + base_url: 'https://my-server.com/v1', api_key: 'key', is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'openai-compatible', + authStyle: 'api_key', + model: 'gpt-4', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'openai'); + assert.equal(config.baseUrl, 'https://my-server.com/v1'); + }); + + it('model override takes precedence', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'Test', provider_type: 'anthropic', protocol: 'anthropic', + base_url: '', api_key: 'key', is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved, 'opus'); + assert.equal(config.modelId, 'opus'); + }); + + it('gemini-image protocol → google SDK', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'Gemini', provider_type: 'gemini-image', protocol: 'gemini-image', + base_url: 'https://generativelanguage.googleapis.com/v1beta', api_key: 'gkey', + is_active: 1, sort_order: 0, + extra_env: '{}', headers_json: '{}', env_overrides_json: '', role_models_json: '{}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'gemini-image', + authStyle: 'api_key', + model: 'gemini-2.5-flash-image', + modelDisplayName: undefined, + upstreamModel: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'google'); + assert.equal(config.apiKey, 'gkey'); + }); + }); +}); + +// ── Entry Point Consistency Tests ─────────────────────────────── + +describe('Entry Point Consistency', () => { + it('all Anthropic-compatible Chinese vendors infer correct protocol from legacy custom type', () => { + const vendors: Array<{ name: string; url: string }> = [ + { name: 'GLM CN', url: 'https://open.bigmodel.cn/api/anthropic' }, + { name: 'GLM Global', url: 'https://api.z.ai/api/anthropic' }, + { name: 'Kimi', url: 'https://api.kimi.com/coding/' }, + { name: 'Moonshot', url: 'https://api.moonshot.cn/anthropic' }, + { name: 'MiniMax CN', url: 'https://api.minimaxi.com/anthropic' }, + { name: 'MiniMax Global', url: 'https://api.minimax.io/anthropic' }, + { name: 'Volcengine', url: 'https://ark.cn-beijing.volces.com/api/coding' }, + { name: 'Bailian', url: 'https://coding.dashscope.aliyuncs.com/apps/anthropic' }, + ]; + + for (const v of vendors) { + const protocol = inferProtocolFromLegacy('custom', v.url); + assert.equal( + protocol, + 'anthropic', + `${v.name} (${v.url}) should infer anthropic, got ${protocol}`, + ); + } + }); + + it('legacy custom type with non-anthropic URL infers openai-compatible', () => { + const protocol = inferProtocolFromLegacy('custom', 'https://my-ollama.local:11434/v1'); + assert.equal(protocol, 'openai-compatible'); + }); +}); + +// ── Env Provider in AI SDK Path ───────────────────────────────── + +describe('Env Provider AI SDK Consistency', () => { + it('env resolution with ANTHROPIC_API_KEY sets hasCredentials=true', () => { + // Simulate having an env var + const origKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-test-env-key'; + try { + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.provider, undefined, 'env mode should have provider=undefined'); + assert.equal(resolved.hasCredentials, true, 'env mode with ANTHROPIC_API_KEY should have hasCredentials=true'); + assert.equal(resolved.protocol, 'anthropic'); + } finally { + if (origKey !== undefined) { + process.env.ANTHROPIC_API_KEY = origKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + + it('env resolution without any credentials sets hasCredentials=false', () => { + const origKey = process.env.ANTHROPIC_API_KEY; + const origToken = process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + try { + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.provider, undefined); + // hasCredentials depends on DB settings too, but with clean env it should be false + // (DB settings may or may not be set in test env, so we just verify provider is undefined) + } finally { + if (origKey !== undefined) process.env.ANTHROPIC_API_KEY = origKey; + if (origToken !== undefined) process.env.ANTHROPIC_AUTH_TOKEN = origToken; + } + }); + + it('toAiSdkConfig with env resolution produces valid anthropic config', () => { + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.modelId, 'sonnet'); + // No apiKey/baseUrl — SDK will read from process.env + assert.equal(config.apiKey, undefined); + assert.equal(config.baseUrl, undefined); + }); +}); + +// ── Upstream Model ID Mapping ─────────────────────────────────── + +describe('Upstream Model ID Mapping', () => { + it('toAiSdkConfig maps internal model ID to upstream via availableModels', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'GLM', provider_type: 'custom', protocol: 'anthropic', + base_url: 'https://open.bigmodel.cn/api/anthropic', api_key: 'key', + is_active: 1, sort_order: 0, extra_env: '{}', headers_json: '{}', + env_overrides_json: '', role_models_json: '{}', notes: '', created_at: '', updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'glm-4.7', // resolved from catalog + modelDisplayName: 'GLM-4.7', + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [ + { modelId: 'sonnet', upstreamModelId: 'glm-4.7', displayName: 'GLM-4.7' }, + { modelId: 'opus', upstreamModelId: 'glm-5', displayName: 'GLM-5' }, + ], + settingSources: ['project', 'local'], + }; + + // Without override — uses resolved.upstreamModel + const config1 = toAiSdkConfig(resolved); + assert.equal(config1.modelId, 'glm-4.7', 'should use upstream model ID from resolution'); + + // With override matching an available model — should map to upstream + const config2 = toAiSdkConfig(resolved, 'opus'); + assert.equal(config2.modelId, 'glm-5', 'override "opus" should map to upstream "glm-5"'); + + // With override NOT in available models — passes through as-is + const config3 = toAiSdkConfig(resolved, 'unknown-model'); + assert.equal(config3.modelId, 'unknown-model', 'unknown override should pass through'); + }); + + it('toClaudeCodeEnv injects role model env vars for upstream mapping', () => { + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'GLM', provider_type: 'custom', protocol: 'anthropic', + base_url: 'https://open.bigmodel.cn/api/anthropic', api_key: 'key', + is_active: 1, sort_order: 0, extra_env: '{}', headers_json: '{}', + env_overrides_json: '', role_models_json: '{"default":"glm-4.7","sonnet":"glm-4.7","opus":"glm-5"}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'glm-4.7', + modelDisplayName: 'GLM-4.7', + headers: {}, + envOverrides: {}, + roleModels: { default: 'glm-4.7', sonnet: 'glm-4.7', opus: 'glm-5' }, + hasCredentials: true, + availableModels: [], + settingSources: ['project', 'local'], + }; + + const env = toClaudeCodeEnv({}, resolved); + assert.equal(env.ANTHROPIC_MODEL, 'glm-4.7', 'ANTHROPIC_MODEL should be set from roleModels.default'); + assert.equal(env.ANTHROPIC_DEFAULT_SONNET_MODEL, 'glm-4.7'); + assert.equal(env.ANTHROPIC_DEFAULT_OPUS_MODEL, 'glm-5'); + }); +}); + +// ── Entry Point Resolution Contract ───────────────────────────── +// Verifies that ALL entry points (chat, bridge, onboarding, check-in, media plan) +// produce identical resolution results for the same inputs, and that the AI SDK +// path does not have any fallback logic outside the unified resolver. + +describe('Entry Point Resolution Contract', () => { + it('env provider with no credentials does not silently fallback', () => { + // When providerId='env' is explicitly selected but shell has no credentials, + // the resolver must return hasCredentials=false. The AI SDK path (text-generator) + // must then throw — NOT silently pick a random DB provider. + const origKey = process.env.ANTHROPIC_API_KEY; + const origToken = process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_AUTH_TOKEN; + try { + const resolved = resolveProvider({ providerId: 'env' }); + assert.equal(resolved.provider, undefined, 'env mode must return provider=undefined'); + // hasCredentials should be false when no env vars are set + // (may be true if legacy DB setting exists, which is also valid) + if (!resolved.hasCredentials) { + // This is the case text-generator should throw on — NOT fallback to DB + assert.equal(resolved.hasCredentials, false); + assert.equal(resolved.provider, undefined); + // Contract: any consumer seeing this result must throw, not fallback + } + } finally { + if (origKey !== undefined) process.env.ANTHROPIC_API_KEY = origKey; + else delete process.env.ANTHROPIC_API_KEY; + if (origToken !== undefined) process.env.ANTHROPIC_AUTH_TOKEN = origToken; + else delete process.env.ANTHROPIC_AUTH_TOKEN; + } + }); + + it('all entry points resolve identically for same providerId + model', () => { + // Simulate what each entry point does: call resolveProvider with the same inputs. + // Chat, bridge, onboarding, check-in, media plan must all get the same result. + const opts = { + sessionProviderId: 'env' as string | undefined, + sessionModel: 'sonnet' as string | undefined, + }; + + const chatResolved = resolveProvider(opts); + const bridgeResolved = resolveProvider(opts); + const onboardingResolved = resolveProvider(opts); + const checkinResolved = resolveProvider(opts); + const planResolved = resolveProvider(opts); + + // All must return identical provider, model, protocol, hasCredentials + for (const [name, r] of [ + ['bridge', bridgeResolved], + ['onboarding', onboardingResolved], + ['checkin', checkinResolved], + ['plan', planResolved], + ] as const) { + assert.equal(r.provider?.id, chatResolved.provider?.id, `${name} provider mismatch`); + assert.equal(r.model, chatResolved.model, `${name} model mismatch`); + assert.equal(r.upstreamModel, chatResolved.upstreamModel, `${name} upstreamModel mismatch`); + assert.equal(r.protocol, chatResolved.protocol, `${name} protocol mismatch`); + assert.equal(r.hasCredentials, chatResolved.hasCredentials, `${name} hasCredentials mismatch`); + } + }); + + it('toAiSdkConfig for env mode does not require provider record', () => { + // env mode: provider=undefined, hasCredentials=true + // toAiSdkConfig must produce a valid config that relies on process.env for auth + const resolved: ResolvedProvider = { + provider: undefined, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'sonnet', + modelDisplayName: undefined, + headers: {}, + envOverrides: {}, + roleModels: {}, + hasCredentials: true, + availableModels: [], + settingSources: ['user', 'project', 'local'], + }; + const config = toAiSdkConfig(resolved); + assert.equal(config.sdkType, 'anthropic'); + assert.equal(config.apiKey, undefined, 'env mode should not inject apiKey — SDK reads from process.env'); + assert.equal(config.baseUrl, undefined, 'env mode should not inject baseUrl — SDK reads from process.env'); + assert.equal(config.modelId, 'sonnet'); + }); + + it('upstream model mapping is consistent between AI SDK and Claude Code paths', () => { + // For a provider where modelId !== upstreamModelId, + // both toAiSdkConfig and toClaudeCodeEnv must use the upstream ID + const resolved: ResolvedProvider = { + provider: { + id: 'test', name: 'GLM', provider_type: 'custom', protocol: 'anthropic', + base_url: 'https://open.bigmodel.cn/api/anthropic', api_key: 'key', + is_active: 1, sort_order: 0, extra_env: '{}', headers_json: '{}', + env_overrides_json: '', role_models_json: '{"default":"glm-4.7"}', + notes: '', created_at: '', updated_at: '', + }, + protocol: 'anthropic', + authStyle: 'api_key', + model: 'sonnet', + upstreamModel: 'glm-4.7', + modelDisplayName: 'GLM-4.7', + headers: {}, + envOverrides: {}, + roleModels: { default: 'glm-4.7' }, + hasCredentials: true, + availableModels: [ + { modelId: 'sonnet', upstreamModelId: 'glm-4.7', displayName: 'GLM-4.7' }, + ], + settingSources: ['project', 'local'], + }; + + // AI SDK path: toAiSdkConfig should use upstreamModel + const aiConfig = toAiSdkConfig(resolved); + assert.equal(aiConfig.modelId, 'glm-4.7', 'AI SDK should use upstream model ID'); + + // Claude Code path: toClaudeCodeEnv should set ANTHROPIC_MODEL from roleModels.default + const ccEnv = toClaudeCodeEnv({}, resolved); + assert.equal(ccEnv.ANTHROPIC_MODEL, 'glm-4.7', 'Claude Code env should use upstream model ID'); + + // Both paths use the same upstream ID + assert.equal(aiConfig.modelId, ccEnv.ANTHROPIC_MODEL, 'AI SDK and Claude Code must use same upstream model'); + }); +}); diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 74c56a6..7ad1fba 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { streamClaude } from '@/lib/claude-client'; -import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db'; +import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db'; +import { resolveProvider as resolveProviderUnified } from '@/lib/provider-resolver'; import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot'; import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types'; import crypto from 'crypto'; @@ -116,26 +117,15 @@ export async function POST(request: NextRequest) { updateSessionModel(session_id, effectiveModel); } - // Resolve provider: explicit provider_id > default_provider_id > environment variables - let resolvedProvider: import('@/types').ApiProvider | undefined; + // Resolve provider via unified resolver (same logic for chat, bridge, onboarding, etc.) const effectiveProviderId = provider_id || session.provider_id || ''; - if (effectiveProviderId && effectiveProviderId !== 'env') { - resolvedProvider = getProvider(effectiveProviderId); - if (!resolvedProvider) { - // Requested provider not found, try default - const defaultId = getDefaultProviderId(); - if (defaultId) { - resolvedProvider = getProvider(defaultId); - } - } - } else if (!effectiveProviderId) { - // No provider specified, try default - const defaultId = getDefaultProviderId(); - if (defaultId) { - resolvedProvider = getProvider(defaultId); - } - } - // effectiveProviderId === 'env' → resolvedProvider stays undefined → uses env vars + const resolved = resolveProviderUnified({ + providerId: effectiveProviderId || undefined, + sessionProviderId: session.provider_id || undefined, + model: model || undefined, + sessionModel: session.model || undefined, + }); + const resolvedProvider = resolved.provider; const providerName = resolvedProvider?.name || ''; if (providerName !== (session.provider_name || '')) { @@ -329,7 +319,7 @@ Start by greeting the user and asking the first question. prompt: content, sessionId: session_id, sdkSessionId: session.sdk_session_id || undefined, - model: effectiveModel, + model: resolved.upstreamModel || resolved.model || effectiveModel, systemPrompt: finalSystemPrompt, workingDirectory: session.sdk_cwd || session.working_directory || undefined, abortController, @@ -338,6 +328,8 @@ Start by greeting the user and asking the first question. imageAgentMode: !!systemPromptAppend, toolTimeoutSeconds: toolTimeout || 300, provider: resolvedProvider, + providerId: effectiveProviderId || undefined, + sessionProviderId: session.provider_id || undefined, mcpServers, conversationHistory: historyMsgs, bypassPermissions: session.permission_profile === 'full_access', diff --git a/src/app/api/media/jobs/plan/route.ts b/src/app/api/media/jobs/plan/route.ts index 2394dbb..a9c61eb 100644 --- a/src/app/api/media/jobs/plan/route.ts +++ b/src/app/api/media/jobs/plan/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; import { streamTextFromProvider } from '@/lib/text-generator'; -import { getDb } from '@/lib/db'; +import { resolveProvider } from '@/lib/provider-resolver'; import fs from 'fs'; import type { PlanMediaJobRequest } from '@/types'; @@ -49,27 +49,20 @@ export async function POST(request: NextRequest) { } // Resolve the provider and model from the session or defaults - const db = getDb(); - let providerId = ''; - let modelId = ''; - + let session: { provider_id: string; model: string } | undefined; if (body.sessionId) { - const session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined; - if (session) { - providerId = session.provider_id; - modelId = session.model; - } + const { getDb } = await import('@/lib/db'); + const db = getDb(); + session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined; } - // Fallback to default provider - if (!providerId) { - const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined; - providerId = defaultId?.value || ''; - } - if (!modelId) { - const defaultModel = db.prepare("SELECT value FROM settings WHERE key = 'default_model'").get() as { value: string } | undefined; - modelId = defaultModel?.value || 'claude-sonnet-4-20250514'; - } + const resolved = resolveProvider({ + sessionProviderId: session?.provider_id || undefined, + sessionModel: session?.model || undefined, + }); + // Preserve 'env' semantics (see onboarding route for rationale) + const providerId = resolved.provider?.id || 'env'; + const modelId = resolved.upstreamModel || resolved.model || session?.model || 'claude-sonnet-4-20250514'; // Read document content let docContent = body.docContent || ''; diff --git a/src/app/api/providers/models/route.ts b/src/app/api/providers/models/route.ts index 914dc54..acd096f 100644 --- a/src/app/api/providers/models/route.ts +++ b/src/app/api/providers/models/route.ts @@ -1,74 +1,31 @@ import { NextResponse } from 'next/server'; -import { getAllProviders, getDefaultProviderId } from '@/lib/db'; +import { getAllProviders, getDefaultProviderId, getModelsForProvider } from '@/lib/db'; import { getContextWindow } from '@/lib/model-context'; +import { getDefaultModelsForProvider, inferProtocolFromLegacy } from '@/lib/provider-catalog'; +import type { Protocol } from '@/lib/provider-catalog'; import type { ErrorResponse, ProviderModelGroup } from '@/types'; -// Default Claude model options +// Default Claude model options (for the built-in 'env' provider) const DEFAULT_MODELS = [ { value: 'sonnet', label: 'Sonnet 4.6' }, { value: 'opus', label: 'Opus 4.6' }, { value: 'haiku', label: 'Haiku 4.5' }, ]; -// Provider-specific model label mappings (base_url -> alias -> display name) -const PROVIDER_MODEL_LABELS: Record = { - 'https://api.z.ai/api/anthropic': [ - { value: 'sonnet', label: 'GLM-4.7' }, - { value: 'opus', label: 'GLM-5' }, - { value: 'haiku', label: 'GLM-4.5-Air' }, - ], - 'https://open.bigmodel.cn/api/anthropic': [ - { value: 'sonnet', label: 'GLM-4.7' }, - { value: 'opus', label: 'GLM-5' }, - { value: 'haiku', label: 'GLM-4.5-Air' }, - ], - 'https://api.kimi.com/coding/': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, - ], - 'https://api.moonshot.ai/anthropic': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, - ], - 'https://api.moonshot.cn/anthropic': [ - { value: 'sonnet', label: 'Kimi K2.5' }, - { value: 'opus', label: 'Kimi K2.5' }, - { value: 'haiku', label: 'Kimi K2.5' }, - ], - 'https://api.minimaxi.com/anthropic': [ - { value: 'sonnet', label: 'MiniMax-M2.5' }, - { value: 'opus', label: 'MiniMax-M2.5' }, - { value: 'haiku', label: 'MiniMax-M2.5' }, - ], - 'https://api.minimax.io/anthropic': [ - { value: 'sonnet', label: 'MiniMax-M2.5' }, - { value: 'opus', label: 'MiniMax-M2.5' }, - { value: 'haiku', label: 'MiniMax-M2.5' }, - ], - 'https://openrouter.ai/api': [ - { value: 'sonnet', label: 'Sonnet 4.6' }, - { value: 'opus', label: 'Opus 4.6' }, - { value: 'haiku', label: 'Haiku 4.5' }, - ], - 'https://coding.dashscope.aliyuncs.com/apps/anthropic': [ - { value: 'qwen3.5-plus', label: 'Qwen 3.5 Plus' }, - { value: 'qwen3-coder-next', label: 'Qwen 3 Coder Next' }, - { value: 'qwen3-coder-plus', label: 'Qwen 3 Coder Plus' }, - { value: 'kimi-k2.5', label: 'Kimi K2.5' }, - { value: 'glm-5', label: 'GLM-5' }, - { value: 'glm-4.7', label: 'GLM-4.7' }, - { value: 'MiniMax-M2.5', label: 'MiniMax-M2.5' }, - ], -}; +interface ModelEntry { + value: string; + label: string; + upstreamModelId?: string; + capabilities?: Record; + variants?: Record; +} /** * Deduplicate models: if multiple aliases map to the same label, keep only the first one. */ -function deduplicateModels(models: { value: string; label: string }[]): { value: string; label: string }[] { +function deduplicateModels(models: ModelEntry[]): ModelEntry[] { const seen = new Set(); - const result: { value: string; label: string }[] = []; + const result: ModelEntry[] = []; for (const m of models) { if (!seen.has(m.label)) { seen.add(m.label); @@ -78,14 +35,16 @@ function deduplicateModels(models: { value: string; label: string }[]): { value: return result; } +/** Media-only provider protocols — skip in chat model selector */ +const MEDIA_PROTOCOLS = new Set(['gemini-image']); +const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']); + export async function GET() { try { const providers = getAllProviders(); const groups: ProviderModelGroup[] = []; // Always show the built-in Claude Code provider group. - // Claude Code CLI stores credentials in ~/.claude/ (via `claude login`), - // which the SDK subprocess can read — even without ANTHROPIC_API_KEY in env. groups.push({ provider_id: 'env', provider_name: 'Claude Code', @@ -118,29 +77,82 @@ export async function GET() { // SDK capabilities not available, keep defaults } - // Provider types that are not LLMs (e.g. image generation) — skip in chat model selector - const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']); - // Build a group for each configured provider for (const provider of providers) { - if (MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue; - const matched = PROVIDER_MODEL_LABELS[provider.base_url]; - let rawModels = matched || DEFAULT_MODELS; + // Determine protocol — use new field if present, otherwise infer from legacy + const protocol: Protocol = (provider.protocol as Protocol) || + inferProtocolFromLegacy(provider.provider_type, provider.base_url); - // For providers with ANTHROPIC_MODEL in extra_env (e.g. Volcengine Ark), - // show the configured model name in the selector - if (!matched) { - try { - const envObj = JSON.parse(provider.extra_env || '{}'); - if (envObj.ANTHROPIC_MODEL) { - rawModels = [{ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL }]; - } - } catch { /* use default */ } + // Skip media-only providers in chat model selector + if (MEDIA_PROTOCOLS.has(protocol) || MEDIA_PROVIDER_TYPES.has(provider.provider_type)) continue; + + // Get models: DB provider_models first, then catalog defaults, then env fallback + let rawModels: ModelEntry[]; + + // 1) Check DB provider_models table + let dbModels: { value: string; label: string; upstreamModelId?: string; capabilities?: Record }[] = []; + try { + const provModels = getModelsForProvider(provider.id); + if (provModels.length > 0) { + dbModels = provModels.map(m => { + let caps: Record | undefined; + let vars: Record | undefined; + try { const p = JSON.parse(m.capabilities_json || '{}'); if (Object.keys(p).length > 0) caps = p; } catch { /* ignore */ } + try { const v = JSON.parse(m.variants_json || '{}'); if (Object.keys(v).length > 0) vars = v; } catch { /* ignore */ } + return { + value: m.model_id, + label: m.display_name || m.model_id, + upstreamModelId: m.upstream_model_id || undefined, + capabilities: caps, + variants: vars, + }; + }); + } + } catch { /* table may not exist in old DBs */ } + + // 2) Catalog defaults + const catalogModels = getDefaultModelsForProvider(protocol, provider.base_url); + const catalogRaw = catalogModels.map(m => ({ + value: m.modelId, + label: m.displayName, + upstreamModelId: m.upstreamModelId, + capabilities: m.capabilities as Record | undefined, + })); + + // Start with DB models + catalog defaults + if (dbModels.length > 0) { + const dbIds = new Set(dbModels.map(m => m.value)); + rawModels = [...dbModels, ...catalogRaw.filter(m => !dbIds.has(m.value))]; + } else if (catalogRaw.length > 0) { + rawModels = [...catalogRaw]; + } else { + rawModels = [...DEFAULT_MODELS]; } + // Inject role_models_json.default into the list if not already present + // (e.g. user configured "ark-code-latest" for a Volcengine or anthropic-thirdparty provider) + try { + const rm = JSON.parse(provider.role_models_json || '{}'); + if (rm.default && !rawModels.some(m => m.value === rm.default)) { + rawModels.unshift({ value: rm.default, label: rm.default }); + } + } catch { /* ignore */ } + + // Legacy: inject ANTHROPIC_MODEL from env overrides if not already present + try { + const envOverrides = provider.env_overrides_json || provider.extra_env || '{}'; + const envObj = JSON.parse(envOverrides); + if (envObj.ANTHROPIC_MODEL && !rawModels.some(m => m.value === envObj.ANTHROPIC_MODEL)) { + rawModels.unshift({ value: envObj.ANTHROPIC_MODEL, label: envObj.ANTHROPIC_MODEL }); + } + } catch { /* ignore */ } + const models = deduplicateModels(rawModels).map(m => { const cw = getContextWindow(m.value); - return cw != null ? { ...m, contextWindow: cw } : m; + return { + ...m, + ...(cw != null ? { contextWindow: cw } : {}), + }; }); groups.push({ diff --git a/src/app/api/workspace/checkin/route.ts b/src/app/api/workspace/checkin/route.ts index 982460d..beb6ff9 100644 --- a/src/app/api/workspace/checkin/route.ts +++ b/src/app/api/workspace/checkin/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; -import { getSetting, getDefaultProviderId, getSession } from '@/lib/db'; +import { getSetting, getSession } from '@/lib/db'; +import { resolveProvider } from '@/lib/provider-resolver'; import { loadState, saveState, writeDailyMemory } from '@/lib/assistant-workspace'; import { getLocalDateString } from '@/lib/utils'; import { generateTextFromProvider } from '@/lib/text-generator'; @@ -42,9 +43,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); } - // Validate that the calling session belongs to this workspace + // Look up the calling session for workspace validation AND provider/model context + let session: ReturnType | undefined; if (sessionId) { - const session = getSession(sessionId); + session = getSession(sessionId) ?? undefined; if (session && session.working_directory !== workspacePath) { return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 }); } @@ -66,8 +68,14 @@ export async function POST(request: NextRequest) { try { existingUser = fs.readFileSync(userPath, 'utf-8'); } catch { /* new file */ } try { - const providerId = getDefaultProviderId() || ''; - const model = getSetting('default_model') || 'claude-sonnet-4-20250514'; + // Resolve using session's provider/model so check-in uses the same provider as chat + const resolved = resolveProvider({ + sessionProviderId: session?.provider_id || undefined, + sessionModel: session?.model || undefined, + }); + // Preserve 'env' semantics (see onboarding route for rationale) + const providerId = resolved.provider?.id || 'env'; + const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; // Generate daily memory entry (episodic, not destructive) const dailyMemoryPrompt = `You maintain daily memory entries for an AI assistant. Given the user's daily check-in answers, generate a daily memory entry for ${today}. diff --git a/src/app/api/workspace/onboarding/route.ts b/src/app/api/workspace/onboarding/route.ts index d981fc2..01b659f 100644 --- a/src/app/api/workspace/onboarding/route.ts +++ b/src/app/api/workspace/onboarding/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import fs from 'fs'; import path from 'path'; -import { getSetting, getDefaultProviderId, getSession } from '@/lib/db'; +import { getSetting, getSession } from '@/lib/db'; +import { resolveProvider } from '@/lib/provider-resolver'; import { loadState, saveState, ensureDailyDir, generateRootDocs } from '@/lib/assistant-workspace'; import { getLocalDateString } from '@/lib/utils'; import { generateTextFromProvider } from '@/lib/text-generator'; @@ -62,9 +63,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid answers format' }, { status: 400 }); } - // Validate that the calling session belongs to this workspace + // Look up the calling session for workspace validation AND provider/model context + let session: ReturnType | undefined; if (sessionId) { - const session = getSession(sessionId); + session = getSession(sessionId) ?? undefined; if (session && session.working_directory !== workspacePath) { return NextResponse.json({ error: 'Session does not belong to current workspace' }, { status: 403 }); } @@ -82,8 +84,15 @@ export async function POST(request: NextRequest) { let memoryContent: string; try { - const providerId = getDefaultProviderId() || ''; - const model = getSetting('default_model') || 'claude-sonnet-4-20250514'; + // Resolve using session's provider/model so onboarding uses the same provider as chat + const resolved = resolveProvider({ + sessionProviderId: session?.provider_id || undefined, + sessionModel: session?.model || undefined, + }); + // Preserve 'env' semantics: when provider=undefined, pass 'env' so text-generator + // uses shell env credentials instead of falling back to a random DB provider + const providerId = resolved.provider?.id || 'env'; + const model = resolved.upstreamModel || resolved.model || getSetting('default_model') || 'claude-sonnet-4-20250514'; const soulPrompt = `Based on the following user onboarding answers, generate a concise "soul.md" file that defines an AI assistant's personality, communication style, and behavioral rules. Write in second person ("You are..."). Keep it under 2000 characters. Use markdown headers and bullet points.\n\n${qaText}`; diff --git a/src/components/settings/ProviderForm.tsx b/src/components/settings/ProviderForm.tsx index 605147a..54d49e7 100644 --- a/src/components/settings/ProviderForm.tsx +++ b/src/components/settings/ProviderForm.tsx @@ -25,12 +25,12 @@ import { Loading02Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-f import type { ApiProvider } from "@/types"; import { useTranslation } from "@/hooks/useTranslation"; -const PROVIDER_PRESETS: Record = { - anthropic: { base_url: "https://api.anthropic.com", extra_env: "{}" }, - openrouter: { base_url: "https://openrouter.ai/api", extra_env: '{"ANTHROPIC_API_KEY":""}' }, - bedrock: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}' }, - vertex: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}' }, - custom: { base_url: "", extra_env: "{}" }, +const PROVIDER_PRESETS: Record = { + anthropic: { base_url: "https://api.anthropic.com", extra_env: "{}", protocol: "anthropic" }, + openrouter: { base_url: "https://openrouter.ai/api", extra_env: '{"ANTHROPIC_API_KEY":""}', protocol: "openrouter" }, + bedrock: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}', protocol: "bedrock" }, + vertex: { base_url: "", extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}', protocol: "vertex" }, + custom: { base_url: "", extra_env: "{}", protocol: "openai-compatible" }, }; const PROVIDER_TYPES = [ @@ -53,9 +53,13 @@ interface ProviderFormProps { export interface ProviderFormData { name: string; provider_type: string; + protocol?: string; base_url: string; api_key: string; extra_env: string; + headers_json?: string; + env_overrides_json?: string; + role_models_json?: string; notes: string; } @@ -73,6 +77,9 @@ export function ProviderForm({ const [apiKey, setApiKey] = useState(""); const [extraEnv, setExtraEnv] = useState("{}"); const [notes, setNotes] = useState(""); + const [headersJson, setHeadersJson] = useState("{}"); + const [envOverridesJson, setEnvOverridesJson] = useState(""); + const [roleModelsJson, setRoleModelsJson] = useState("{}"); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [showAdvanced, setShowAdvanced] = useState(false); @@ -90,11 +97,17 @@ export function ProviderForm({ setBaseUrl(provider.base_url); setApiKey(""); setExtraEnv(provider.extra_env || "{}"); + setHeadersJson(provider.headers_json || "{}"); + setEnvOverridesJson(provider.env_overrides_json || ""); + setRoleModelsJson(provider.role_models_json || "{}"); setNotes(provider.notes || ""); - // Show advanced if extra_env has content + // Show advanced if extra_env or new fields have content try { const parsed = JSON.parse(provider.extra_env || "{}"); - setShowAdvanced(Object.keys(parsed).length > 0); + const hasHeaders = provider.headers_json && provider.headers_json !== "{}"; + const hasEnvOverrides = provider.env_overrides_json && provider.env_overrides_json !== ""; + const hasRoleModels = provider.role_models_json && provider.role_models_json !== "{}"; + setShowAdvanced(Object.keys(parsed).length > 0 || !!hasHeaders || !!hasEnvOverrides || !!hasRoleModels); } catch { setShowAdvanced(true); } @@ -119,6 +132,9 @@ export function ProviderForm({ setBaseUrl(PROVIDER_PRESETS.anthropic.base_url); setApiKey(""); setExtraEnv("{}"); + setHeadersJson("{}"); + setEnvOverridesJson(""); + setRoleModelsJson("{}"); setNotes(""); setShowAdvanced(false); } @@ -146,23 +162,36 @@ export function ProviderForm({ return; } - // Validate extra_env JSON - try { - JSON.parse(extraEnv); - } catch { - setError("Extra environment variables must be valid JSON"); - return; + // Validate JSON fields + for (const [label, val] of [ + ["Extra environment variables", extraEnv], + ["Headers", headersJson], + ["Role models", roleModelsJson], + ] as const) { + if (val && val.trim()) { + try { JSON.parse(val); } catch { + setError(`${label} must be valid JSON`); + return; + } + } } setSaving(true); setError(null); try { + // Always sync protocol with provider_type to prevent stale protocol after edits + const derivedProtocol = PROVIDER_PRESETS[providerType]?.protocol || providerType; + await onSave({ name: name.trim(), provider_type: providerType, + protocol: derivedProtocol, base_url: baseUrl.trim(), api_key: apiKey, extra_env: extraEnv, + headers_json: headersJson.trim() || "{}", + env_overrides_json: envOverridesJson.trim() || "", + role_models_json: roleModelsJson.trim() || "{}", notes: notes.trim(), }); onOpenChange(false); @@ -277,6 +306,48 @@ export function ProviderForm({ /> +
+ +