feat: unified provider resolver with protocol-based dispatch
Extract provider resolution logic into two new modules: - provider-catalog.ts: vendor presets, default model catalogs, protocol definitions - provider-resolver.ts: unified resolution for all consumers (Claude SDK, AI SDK, model listing) DB migration: - Add protocol, headers_json, env_overrides_json, role_models_json columns to api_providers - Create provider_models table for per-provider model catalog Consumer refactoring: - claude-client.ts: replace inline env-building with toClaudeCodeEnv() - text-generator.ts: replace inline config with toAiSdkConfig() - conversation-engine.ts, onboarding, checkin, plan routes: use shared resolveProvider() - /api/providers/models: read from catalog + provider_models table UI improvements: - Merge confusing "Custom API (Anthropic-compatible)" into "Anthropic Third-party API" - Add 3-field model name mapping (sonnet/opus/haiku) in advanced options - ProviderForm.tsx: support new fields (protocol, env_overrides, role_models) Bug fixes: - Protect auth credentials from being deleted by legacy extra_env empty-string overrides - Fix env var names: ANTHROPIC_DEFAULT_SONNET → ANTHROPIC_DEFAULT_SONNET_MODEL (SDK convention) Tests: 229/229 passed. Verified with aiberm (no mapping) and pipellm (with mapping). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4dc787c4a4
commit
f76e4747cd
21 files changed
+2932
-344
No files matched your search
@@ -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
|
||||
Generated
+273
-15
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+13
-21
@@ -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',
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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<string, { value: string; label: string }[]> = {
|
||||
'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<string, unknown>;
|
||||
variants?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
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<string>(['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<string, unknown> }[] = [];
|
||||
try {
|
||||
const provModels = getModelsForProvider(provider.id);
|
||||
if (provModels.length > 0) {
|
||||
dbModels = provModels.map(m => {
|
||||
let caps: Record<string, unknown> | undefined;
|
||||
let vars: Record<string, unknown> | 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<string, unknown> | 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({
|
||||
|
||||
@@ -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<typeof getSession> | 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}.
|
||||
|
||||
@@ -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<typeof getSession> | 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}`;
|
||||
|
||||
|
||||
@@ -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<string, { base_url: string; extra_env: string }> = {
|
||||
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<string, { base_url: string; extra_env: string; protocol: string }> = {
|
||||
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<string | null>(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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-headers-json" className="text-xs text-muted-foreground">
|
||||
Headers (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-headers-json"
|
||||
placeholder='{"X-Custom-Header": "value"}'
|
||||
value={headersJson}
|
||||
onChange={(e) => setHeadersJson(e.target.value)}
|
||||
className="font-mono text-sm min-h-[60px]"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-env-overrides" className="text-xs text-muted-foreground">
|
||||
Env Overrides (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-env-overrides"
|
||||
placeholder='{"CLAUDE_CODE_USE_BEDROCK": "1"}'
|
||||
value={envOverridesJson}
|
||||
onChange={(e) => setEnvOverridesJson(e.target.value)}
|
||||
className="font-mono text-sm min-h-[60px]"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-role-models" className="text-xs text-muted-foreground">
|
||||
Role Models (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="provider-role-models"
|
||||
placeholder='{"default": "sonnet", "reasoning": "opus", "small": "haiku"}'
|
||||
value={roleModelsJson}
|
||||
onChange={(e) => setRoleModelsJson(e.target.value)}
|
||||
className="font-mono text-sm min-h-[60px]"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-notes" className="text-xs text-muted-foreground">
|
||||
{t('provider.notes')}
|
||||
|
||||
@@ -89,26 +89,31 @@ interface QuickPreset {
|
||||
icon: ReactNode;
|
||||
// Pre-filled provider data
|
||||
provider_type: string;
|
||||
/** Wire protocol — determines how the provider is dispatched at runtime */
|
||||
protocol: string;
|
||||
base_url: string;
|
||||
extra_env: string;
|
||||
// Which fields user must fill
|
||||
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names")[];
|
||||
fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names" | "model_mapping")[];
|
||||
// Category: 'chat' (default) or 'media'
|
||||
category?: "chat" | "media";
|
||||
}
|
||||
|
||||
const QUICK_PRESETS: QuickPreset[] = [
|
||||
// ── Custom endpoints ──
|
||||
{
|
||||
key: "custom-api",
|
||||
name: "Custom API",
|
||||
description: "Custom API endpoint — fill in all fields",
|
||||
descriptionZh: "自定义 API 端点 — 填写所有信息",
|
||||
key: "custom-openai",
|
||||
name: "Custom API (OpenAI-compatible)",
|
||||
description: "OpenAI-compatible custom endpoint",
|
||||
descriptionZh: "自定义 OpenAI 兼容 API 端点",
|
||||
icon: <HugeiconsIcon icon={Settings02Icon} className="h-[18px] w-[18px] text-muted-foreground" />,
|
||||
provider_type: "custom",
|
||||
protocol: "openai-compatible",
|
||||
base_url: "",
|
||||
extra_env: "{}",
|
||||
fields: ["name", "api_key", "base_url", "extra_env"],
|
||||
},
|
||||
// ── Anthropic-compatible services ──
|
||||
{
|
||||
key: "anthropic-thirdparty",
|
||||
name: "Anthropic Third-party API",
|
||||
@@ -116,9 +121,10 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥",
|
||||
icon: <Anthropic size={18} />,
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["name", "api_key", "base_url", "model_names"],
|
||||
fields: ["name", "api_key", "base_url", "model_mapping"],
|
||||
},
|
||||
{
|
||||
key: "anthropic-official",
|
||||
@@ -127,6 +133,7 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "Anthropic 官方 API",
|
||||
icon: <Anthropic size={18} />,
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.anthropic.com",
|
||||
extra_env: "{}",
|
||||
fields: ["api_key"],
|
||||
@@ -138,6 +145,7 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "通过 OpenRouter 访问多种模型",
|
||||
icon: <OpenRouter size={18} />,
|
||||
provider_type: "openrouter",
|
||||
protocol: "openrouter",
|
||||
base_url: "https://openrouter.ai/api",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -148,7 +156,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Zhipu GLM Code Plan — China region",
|
||||
descriptionZh: "智谱 GLM 编程套餐 — 中国区",
|
||||
icon: <Zhipu size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://open.bigmodel.cn/api/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -159,7 +168,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Zhipu GLM Code Plan — Global region",
|
||||
descriptionZh: "智谱 GLM 编程套餐 — 国际区",
|
||||
icon: <Zhipu size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.z.ai/api/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -170,7 +180,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Kimi Coding Plan API",
|
||||
descriptionZh: "Kimi 编程计划 API",
|
||||
icon: <Kimi size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.kimi.com/coding/",
|
||||
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -181,7 +192,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Moonshot AI API",
|
||||
descriptionZh: "月之暗面 API",
|
||||
icon: <Moonshot size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.moonshot.cn/anthropic",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -192,7 +204,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "MiniMax Code Plan — China region",
|
||||
descriptionZh: "MiniMax 编程套餐 — 中国区",
|
||||
icon: <Minimax size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.minimaxi.com/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -203,7 +216,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "MiniMax Code Plan — Global region",
|
||||
descriptionZh: "MiniMax 编程套餐 — 国际区",
|
||||
icon: <Minimax size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://api.minimax.io/anthropic",
|
||||
extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -214,7 +228,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi",
|
||||
descriptionZh: "字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi",
|
||||
icon: <Volcengine size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}',
|
||||
fields: ["api_key", "model_names"],
|
||||
@@ -225,11 +240,13 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
description: "Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax",
|
||||
descriptionZh: "阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax",
|
||||
icon: <Bailian size={18} />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "https://coding.dashscope.aliyuncs.com/apps/anthropic",
|
||||
extra_env: '{"ANTHROPIC_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
},
|
||||
// ── Cloud platform providers ──
|
||||
{
|
||||
key: "bedrock",
|
||||
name: "AWS Bedrock",
|
||||
@@ -237,6 +254,7 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "Amazon Bedrock — 需要 AWS 凭证",
|
||||
icon: <Bedrock size={18} />,
|
||||
provider_type: "bedrock",
|
||||
protocol: "bedrock",
|
||||
base_url: "",
|
||||
extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}',
|
||||
fields: ["extra_env"],
|
||||
@@ -248,21 +266,25 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "Google Vertex AI — 需要 GCP 凭证",
|
||||
icon: <Google size={18} />,
|
||||
provider_type: "vertex",
|
||||
protocol: "vertex",
|
||||
base_url: "",
|
||||
extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}',
|
||||
fields: ["extra_env"],
|
||||
},
|
||||
// ── Proxy / gateway ──
|
||||
{
|
||||
key: "litellm",
|
||||
name: "LiteLLM",
|
||||
description: "LiteLLM proxy — local or remote",
|
||||
descriptionZh: "LiteLLM 代理 — 本地或远程",
|
||||
icon: <HugeiconsIcon icon={ServerStack01Icon} className="h-[18px] w-[18px] text-muted-foreground" />,
|
||||
provider_type: "custom",
|
||||
provider_type: "anthropic",
|
||||
protocol: "anthropic",
|
||||
base_url: "http://localhost:4000",
|
||||
extra_env: "{}",
|
||||
fields: ["api_key", "base_url"],
|
||||
},
|
||||
// ── Media providers ──
|
||||
{
|
||||
key: "gemini-image",
|
||||
name: "Google Gemini (Image)",
|
||||
@@ -270,6 +292,7 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成",
|
||||
icon: <Google size={18} />,
|
||||
provider_type: "gemini-image",
|
||||
protocol: "gemini-image",
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta",
|
||||
extra_env: '{"GEMINI_API_KEY":""}',
|
||||
fields: ["api_key"],
|
||||
@@ -314,6 +337,10 @@ function PresetConnectDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [extraEnv, setExtraEnv] = useState("{}");
|
||||
const [modelName, setModelName] = useState("");
|
||||
// Model mapping fields (sonnet/opus/haiku → actual API model IDs)
|
||||
const [mapSonnet, setMapSonnet] = useState("");
|
||||
const [mapOpus, setMapOpus] = useState("");
|
||||
const [mapHaiku, setMapHaiku] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -328,6 +355,9 @@ function PresetConnectDialog({
|
||||
setName(preset.name);
|
||||
setExtraEnv(preset.extra_env);
|
||||
setModelName("");
|
||||
setMapSonnet("");
|
||||
setMapOpus("");
|
||||
setMapHaiku("");
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
setShowAdvanced(false);
|
||||
@@ -339,15 +369,31 @@ function PresetConnectDialog({
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
let finalExtraEnv = extraEnv;
|
||||
const finalExtraEnv = extraEnv;
|
||||
let roleModelsJson = "{}";
|
||||
|
||||
// Inject model name into extra_env if model_names field is used
|
||||
// Model mapping (sonnet/opus/haiku → actual API model IDs)
|
||||
if (preset.fields.includes("model_mapping")) {
|
||||
const hasAny = mapSonnet.trim() || mapOpus.trim() || mapHaiku.trim();
|
||||
if (hasAny) {
|
||||
// If user fills any, all 3 are required
|
||||
if (!mapSonnet.trim() || !mapOpus.trim() || !mapHaiku.trim()) {
|
||||
setError(isZh
|
||||
? '模型映射需要同时填写 Sonnet、Opus、Haiku 三个模型名称'
|
||||
: 'Model mapping requires all 3 model names (Sonnet, Opus, Haiku)');
|
||||
return;
|
||||
}
|
||||
roleModelsJson = JSON.stringify({
|
||||
sonnet: mapSonnet.trim(),
|
||||
opus: mapOpus.trim(),
|
||||
haiku: mapHaiku.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inject model name into role_models_json (preferred) instead of extra_env.ANTHROPIC_MODEL
|
||||
if (preset.fields.includes("model_names") && modelName.trim()) {
|
||||
try {
|
||||
const envObj = JSON.parse(finalExtraEnv);
|
||||
envObj["ANTHROPIC_MODEL"] = modelName.trim();
|
||||
finalExtraEnv = JSON.stringify(envObj);
|
||||
} catch { /* use as-is */ }
|
||||
roleModelsJson = JSON.stringify({ default: modelName.trim() });
|
||||
}
|
||||
|
||||
// Validate extra_env JSON
|
||||
@@ -363,9 +409,11 @@ function PresetConnectDialog({
|
||||
await onAdd({
|
||||
name: name.trim() || preset.name,
|
||||
provider_type: preset.provider_type,
|
||||
protocol: preset.protocol,
|
||||
base_url: baseUrl.trim(),
|
||||
api_key: apiKey,
|
||||
extra_env: finalExtraEnv,
|
||||
role_models_json: roleModelsJson,
|
||||
notes: "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
@@ -477,14 +525,53 @@ function PresetConnectDialog({
|
||||
{t('provider.advancedOptions')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="space-y-2 border-t border-border/50 pt-3">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
|
||||
<Textarea
|
||||
value={extraEnv}
|
||||
onChange={(e) => setExtraEnv(e.target.value)}
|
||||
className="text-sm font-mono min-h-[60px]"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="space-y-4 border-t border-border/50 pt-3">
|
||||
{/* Model mapping (sonnet/opus/haiku → API model IDs) */}
|
||||
{preset.fields.includes("model_mapping") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{isZh ? '模型名称映射' : 'Model Name Mapping'}
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{isZh
|
||||
? '如果服务商使用不同的模型名称(如 claude-sonnet-4-6),在此映射。留空则使用默认名称(sonnet / opus / haiku)。'
|
||||
: 'Map model names if the provider uses different IDs (e.g. claude-sonnet-4-6). Leave empty to use defaults (sonnet / opus / haiku).'}
|
||||
</p>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 items-center">
|
||||
<span className="text-xs text-muted-foreground text-right">Sonnet</span>
|
||||
<Input
|
||||
value={mapSonnet}
|
||||
onChange={(e) => setMapSonnet(e.target.value)}
|
||||
placeholder="claude-sonnet-4-6"
|
||||
className="text-sm font-mono h-8"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground text-right">Opus</span>
|
||||
<Input
|
||||
value={mapOpus}
|
||||
onChange={(e) => setMapOpus(e.target.value)}
|
||||
placeholder="claude-opus-4-6"
|
||||
className="text-sm font-mono h-8"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground text-right">Haiku</span>
|
||||
<Input
|
||||
value={mapHaiku}
|
||||
onChange={(e) => setMapHaiku(e.target.value)}
|
||||
placeholder="claude-haiku-4-5-20251001"
|
||||
className="text-sm font-mono h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('provider.extraEnvVars')} (JSON)</Label>
|
||||
<Textarea
|
||||
value={extraEnv}
|
||||
onChange={(e) => setExtraEnv(e.target.value)}
|
||||
className="text-sm font-mono min-h-[60px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -102,7 +102,7 @@ async function deliverResponse(
|
||||
if (adapter.channelType === 'telegram') {
|
||||
const chunks = markdownToTelegramChunks(responseText, 4096);
|
||||
if (chunks.length > 0) {
|
||||
return deliverRendered(adapter, address, chunks, { sessionId });
|
||||
return deliverRendered(adapter, address, chunks, { sessionId, replyToMessageId });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -722,8 +722,8 @@ async function handleCommand(
|
||||
break;
|
||||
}
|
||||
const binding = router.resolve(msg.address);
|
||||
router.updateBinding(binding.id, { workingDirectory: validatedPath });
|
||||
response = `Working directory set to <code>${escapeHtml(validatedPath)}</code>`;
|
||||
router.updateBinding(binding.id, { workingDirectory: validatedPath, sdkSessionId: '' });
|
||||
response = `Working directory set to <code>${escapeHtml(validatedPath)}</code>\n(SDK session reset — next message starts fresh context)`;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,10 @@ export function createBinding(
|
||||
channelType: address.channelType,
|
||||
chatId: address.chatId,
|
||||
codepilotSessionId: session.id,
|
||||
sdkSessionId: '',
|
||||
workingDirectory: defaultCwd,
|
||||
model: defaultModel,
|
||||
mode: 'code',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,8 +85,10 @@ export function bindToSession(
|
||||
channelType: address.channelType,
|
||||
chatId: address.chatId,
|
||||
codepilotSessionId,
|
||||
sdkSessionId: '',
|
||||
workingDirectory: session.working_directory,
|
||||
model: session.model,
|
||||
mode: 'code',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { ChannelBinding } from './types';
|
||||
import type { SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import type { SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, MCPServerConfig } from '@/types';
|
||||
import { streamClaude } from '../claude-client';
|
||||
import {
|
||||
addMessage,
|
||||
@@ -22,12 +23,30 @@ import {
|
||||
updateSessionModel,
|
||||
syncSdkTasks,
|
||||
getSession,
|
||||
getProvider,
|
||||
getDefaultProviderId,
|
||||
getSetting,
|
||||
} from '../db';
|
||||
import { resolveProvider as resolveProviderUnified } from '../provider-resolver';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */
|
||||
function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
try {
|
||||
const readJson = (p: string): Record<string, unknown> => {
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
|
||||
};
|
||||
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
|
||||
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
|
||||
const merged = {
|
||||
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
};
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PermissionRequestInfo {
|
||||
permissionRequestId: string;
|
||||
toolName: string;
|
||||
@@ -133,19 +152,16 @@ export async function processMessage(
|
||||
}
|
||||
addMessage(sessionId, 'user', savedContent);
|
||||
|
||||
// Resolve provider
|
||||
let resolvedProvider: import('@/types').ApiProvider | undefined;
|
||||
const providerId = session?.provider_id || '';
|
||||
if (providerId && providerId !== 'env') {
|
||||
resolvedProvider = getProvider(providerId);
|
||||
}
|
||||
if (!resolvedProvider) {
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (defaultId) resolvedProvider = getProvider(defaultId);
|
||||
}
|
||||
// Resolve provider via unified resolver (same logic as desktop chat route)
|
||||
const resolved = resolveProviderUnified({
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
model: binding.model || undefined,
|
||||
sessionModel: session?.model || undefined,
|
||||
});
|
||||
const resolvedProvider = resolved.provider;
|
||||
|
||||
// Effective model
|
||||
const effectiveModel = binding.model || session?.model || getSetting('default_model') || undefined;
|
||||
// Use upstream model from unified resolver (same chain as chat route)
|
||||
const effectiveModel = resolved.upstreamModel || resolved.model || binding.model || session?.model || getSetting('default_model') || undefined;
|
||||
|
||||
// Permission mode from binding mode
|
||||
let permissionMode: string;
|
||||
@@ -174,6 +190,10 @@ export async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Load MCP servers from Claude config files so the SDK has access to
|
||||
// user-level MCP tools, matching the desktop chat route behavior.
|
||||
const mcpServers = loadMcpServers();
|
||||
|
||||
const stream = streamClaude({
|
||||
prompt: text,
|
||||
sessionId,
|
||||
@@ -184,6 +204,8 @@ export async function processMessage(
|
||||
abortController,
|
||||
permissionMode,
|
||||
provider: resolvedProvider,
|
||||
sessionProviderId: session?.provider_id || undefined,
|
||||
mcpServers,
|
||||
conversationHistory: historyMsgs,
|
||||
files,
|
||||
bypassPermissions,
|
||||
|
||||
@@ -274,7 +274,7 @@ export async function deliverRendered(
|
||||
adapter: BaseChannelAdapter,
|
||||
address: ChannelAddress,
|
||||
chunks: TelegramChunk[],
|
||||
opts?: { sessionId?: string; dedupKey?: string },
|
||||
opts?: { sessionId?: string; dedupKey?: string; replyToMessageId?: string },
|
||||
): Promise<SendResult> {
|
||||
// Dedup check
|
||||
if (opts?.dedupKey) {
|
||||
@@ -300,6 +300,8 @@ export async function deliverRendered(
|
||||
address,
|
||||
text: chunk.html,
|
||||
parseMode: 'HTML',
|
||||
// Reply to the original message on the first chunk for threading
|
||||
replyToMessageId: i === 0 ? opts?.replyToMessageId : undefined,
|
||||
};
|
||||
|
||||
// Try HTML first, fall back to plain text on parse error
|
||||
|
||||
+27
-62
@@ -1,6 +1,7 @@
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type {
|
||||
SDKMessage,
|
||||
NotificationHookInput,
|
||||
PostToolUseHookInput,
|
||||
SDKAssistantMessage,
|
||||
SDKUserMessage,
|
||||
SDKResultMessage,
|
||||
@@ -12,15 +13,14 @@ import type {
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
McpServerConfig,
|
||||
NotificationHookInput,
|
||||
PostToolUseHookInput,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment, ApiProvider } from '@/types';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment } from '@/types';
|
||||
import { isImageFile } from '@/types';
|
||||
import { registerPendingPermission } from './permission-registry';
|
||||
import { registerConversation, unregisterConversation } from './conversation-registry';
|
||||
import { captureCapabilities } from './agent-sdk-capabilities';
|
||||
import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db';
|
||||
import { getSetting, updateSdkSessionId, createPermissionRequest } from './db';
|
||||
import { resolveForClaudeCode, toClaudeCodeEnv } from './provider-resolver';
|
||||
import { findClaudeBinary, findGitBash, getExpandedPath } from './platform';
|
||||
import { notifyPermissionRequest, notifyGeneric } from './telegram-bot';
|
||||
import os from 'os';
|
||||
@@ -293,8 +293,14 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
|
||||
return new ReadableStream<string>({
|
||||
async start(controller) {
|
||||
// Hoist activeProvider so it's accessible in the catch block for error messages
|
||||
const activeProvider: ApiProvider | undefined = options.provider ?? getActiveProvider();
|
||||
// Resolve provider via the unified resolver. The caller may pass an explicit
|
||||
// provider (from resolveProvider().provider), or undefined when 'env' mode is
|
||||
// intended. We do NOT fall back to getActiveProvider() here — that's handled
|
||||
// inside resolveForClaudeCode() only when no resolution was attempted at all.
|
||||
const resolved = resolveForClaudeCode(options.provider, {
|
||||
providerId: options.providerId,
|
||||
sessionProviderId: options.sessionProviderId,
|
||||
});
|
||||
|
||||
try {
|
||||
// Build env for the Claude Code subprocess.
|
||||
@@ -322,54 +328,15 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProvider && activeProvider.api_key) {
|
||||
// Clear all existing ANTHROPIC_* variables to prevent conflicts
|
||||
for (const key of Object.keys(sdkEnv)) {
|
||||
if (key.startsWith('ANTHROPIC_')) {
|
||||
delete sdkEnv[key];
|
||||
}
|
||||
}
|
||||
// Build env from resolved provider
|
||||
const resolvedEnv = toClaudeCodeEnv(sdkEnv, resolved);
|
||||
// toClaudeCodeEnv returns a full env — merge back into sdkEnv
|
||||
// (preserves HOME, USERPROFILE, PATH, Git Bash detection set above)
|
||||
Object.assign(sdkEnv, resolvedEnv);
|
||||
|
||||
// Inject provider config — set both token variants so extra_env can clear the unwanted one
|
||||
sdkEnv.ANTHROPIC_AUTH_TOKEN = activeProvider.api_key;
|
||||
sdkEnv.ANTHROPIC_API_KEY = activeProvider.api_key;
|
||||
if (activeProvider.base_url) {
|
||||
sdkEnv.ANTHROPIC_BASE_URL = activeProvider.base_url;
|
||||
}
|
||||
|
||||
// Inject extra environment variables
|
||||
// Empty string values mean "delete this variable" (e.g. clear ANTHROPIC_API_KEY for AUTH_TOKEN-only providers)
|
||||
try {
|
||||
const extraEnv = JSON.parse(activeProvider.extra_env || '{}');
|
||||
for (const [key, value] of Object.entries(extraEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') {
|
||||
delete sdkEnv[key];
|
||||
} else {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed extra_env
|
||||
}
|
||||
} else {
|
||||
// No active provider — check legacy DB settings first, then fall back to
|
||||
// environment variables already present in process.env (copied into sdkEnv above).
|
||||
// This allows users who set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL
|
||||
// in their shell environment to use them without configuring a provider in the UI.
|
||||
const appToken = getSetting('anthropic_auth_token');
|
||||
const appBaseUrl = getSetting('anthropic_base_url');
|
||||
if (appToken) {
|
||||
sdkEnv.ANTHROPIC_AUTH_TOKEN = appToken;
|
||||
}
|
||||
if (appBaseUrl) {
|
||||
sdkEnv.ANTHROPIC_BASE_URL = appBaseUrl;
|
||||
}
|
||||
// If neither legacy settings nor env vars provide a key, log a warning
|
||||
if (!appToken && !sdkEnv.ANTHROPIC_API_KEY && !sdkEnv.ANTHROPIC_AUTH_TOKEN) {
|
||||
console.warn('[claude-client] No API key found: no active provider, no legacy settings, and no ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN in environment');
|
||||
}
|
||||
// Warn if no credentials found at all
|
||||
if (!resolved.hasCredentials && !sdkEnv.ANTHROPIC_API_KEY && !sdkEnv.ANTHROPIC_AUTH_TOKEN) {
|
||||
console.warn('[claude-client] No API key found: no active provider, no legacy settings, and no ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN in environment');
|
||||
}
|
||||
|
||||
// Check if dangerously_skip_permissions is enabled globally or per-session
|
||||
@@ -389,9 +356,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
// CodePilot, skip 'user' settings because ~/.claude/settings.json
|
||||
// may contain env overrides (ANTHROPIC_BASE_URL, ANTHROPIC_MODEL,
|
||||
// etc.) that would conflict with the provider's configuration.
|
||||
settingSources: activeProvider?.api_key
|
||||
? ['project', 'local']
|
||||
: ['user', 'project', 'local'],
|
||||
settingSources: resolved.settingSources as Options['settingSources'],
|
||||
};
|
||||
|
||||
if (skipPermissions) {
|
||||
@@ -762,7 +727,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
|
||||
// Fire-and-forget: capture SDK capabilities for UI consumption
|
||||
// Scope to provider so different providers don't pollute each other's cache
|
||||
const capProviderId = activeProvider?.api_key ? (options.provider as ApiProvider & { id?: string })?.id || 'custom' : 'env';
|
||||
const capProviderId = resolved.provider?.api_key ? resolved.provider.id || 'custom' : 'env';
|
||||
captureCapabilities(sessionId, conversation, capProviderId).catch((err) => {
|
||||
console.warn('[claude-client] Capability capture failed:', err);
|
||||
});
|
||||
@@ -960,19 +925,19 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
if (code === 'ENOENT' || rawMessage.includes('ENOENT') || rawMessage.includes('spawn')) {
|
||||
errorMessage = `Claude Code CLI not found. Please ensure Claude Code is installed and available in your PATH.\n\nOriginal error: ${rawMessage}`;
|
||||
} else if (rawMessage.includes('exited with code 1') || rawMessage.includes('exit code 1')) {
|
||||
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
|
||||
const providerHint = resolved.provider?.name ? ` (Provider: ${resolved.provider?.name})` : '';
|
||||
const detailHint = extraDetail ? `\n\nDetails: ${extraDetail}` : '';
|
||||
const hasImages = files && files.some(f => isImageFile(f.type));
|
||||
const imageHint = hasImages ? '\n• Provider may not support image/vision input' : '';
|
||||
errorMessage = `Claude Code process exited with an error${providerHint}. This is often caused by:\n• Invalid or missing API Key\n• Incorrect Base URL configuration\n• Network connectivity issues${imageHint}${detailHint}\n\nOriginal error: ${rawMessage}`;
|
||||
} else if (rawMessage.includes('exited with code')) {
|
||||
const providerHint = activeProvider?.name ? ` (Provider: ${activeProvider.name})` : '';
|
||||
const providerHint = resolved.provider?.name ? ` (Provider: ${resolved.provider?.name})` : '';
|
||||
errorMessage = `Claude Code process crashed unexpectedly${providerHint}.\n\nOriginal error: ${rawMessage}`;
|
||||
} else if (code === 'ECONNREFUSED' || rawMessage.includes('ECONNREFUSED') || rawMessage.includes('fetch failed')) {
|
||||
const baseUrl = activeProvider?.base_url || 'default';
|
||||
const baseUrl = resolved.provider?.base_url || 'default';
|
||||
errorMessage = `Cannot connect to API endpoint (${baseUrl}). Please check your network connection and Base URL configuration.\n\nOriginal error: ${rawMessage}`;
|
||||
} else if (rawMessage.includes('401') || rawMessage.includes('Unauthorized') || rawMessage.includes('authentication')) {
|
||||
const providerHint = activeProvider?.name ? ` for provider "${activeProvider.name}"` : '';
|
||||
const providerHint = resolved.provider?.name ? ` for provider "${resolved.provider?.name}"` : '';
|
||||
errorMessage = `Authentication failed${providerHint}. Please verify your API Key is correct and has not expired.\n\nOriginal error: ${rawMessage}`;
|
||||
} else if (rawMessage.includes('403') || rawMessage.includes('Forbidden')) {
|
||||
errorMessage = `Access denied. Your API Key may not have permission for this operation.\n\nOriginal error: ${rawMessage}`;
|
||||
|
||||
+103
-3
@@ -397,6 +397,43 @@ function migrateDb(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
|
||||
// Add new provider fields (protocol, headers, env_overrides, role_models)
|
||||
{
|
||||
const providerCols = db.prepare("PRAGMA table_info(api_providers)").all() as { name: string }[];
|
||||
const provColNames = providerCols.map(c => c.name);
|
||||
if (!provColNames.includes('protocol')) {
|
||||
db.exec("ALTER TABLE api_providers ADD COLUMN protocol TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!provColNames.includes('headers_json')) {
|
||||
db.exec("ALTER TABLE api_providers ADD COLUMN headers_json TEXT NOT NULL DEFAULT '{}'");
|
||||
}
|
||||
if (!provColNames.includes('env_overrides_json')) {
|
||||
db.exec("ALTER TABLE api_providers ADD COLUMN env_overrides_json TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!provColNames.includes('role_models_json')) {
|
||||
db.exec("ALTER TABLE api_providers ADD COLUMN role_models_json TEXT NOT NULL DEFAULT '{}'");
|
||||
}
|
||||
}
|
||||
|
||||
// Create provider_models table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS provider_models (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
upstream_model_id TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
||||
variants_json TEXT NOT NULL DEFAULT '{}',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (provider_id) REFERENCES api_providers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_provider_models_provider_id ON provider_models(provider_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_models_provider_model ON provider_models(provider_id, model_id);
|
||||
`);
|
||||
|
||||
// Ensure media_generations table exists for databases created before this migration
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS media_generations (
|
||||
@@ -1018,16 +1055,21 @@ export function createProvider(data: CreateProviderRequest): ApiProvider {
|
||||
const sortOrder = (maxRow.max_order ?? -1) + 1;
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO api_providers (id, name, provider_type, base_url, api_key, is_active, sort_order, extra_env, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
`INSERT INTO api_providers (id, name, provider_type, protocol, base_url, api_key, is_active, sort_order, extra_env, headers_json, env_overrides_json, role_models_json, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
data.name,
|
||||
data.provider_type || 'anthropic',
|
||||
data.protocol || '',
|
||||
data.base_url || '',
|
||||
data.api_key || '',
|
||||
0,
|
||||
sortOrder,
|
||||
data.extra_env || '{}',
|
||||
data.headers_json || '{}',
|
||||
data.env_overrides_json || '',
|
||||
data.role_models_json || '{}',
|
||||
data.notes || '',
|
||||
now,
|
||||
now,
|
||||
@@ -1044,15 +1086,21 @@ export function updateProvider(id: string, data: UpdateProviderRequest): ApiProv
|
||||
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
||||
const name = data.name ?? existing.name;
|
||||
const providerType = data.provider_type ?? existing.provider_type;
|
||||
const protocol = data.protocol ?? existing.protocol;
|
||||
const baseUrl = data.base_url ?? existing.base_url;
|
||||
const apiKey = data.api_key ?? existing.api_key;
|
||||
const extraEnv = data.extra_env ?? existing.extra_env;
|
||||
const headersJson = data.headers_json ?? existing.headers_json;
|
||||
const envOverridesJson = data.env_overrides_json ?? existing.env_overrides_json;
|
||||
const roleModelsJson = data.role_models_json ?? existing.role_models_json;
|
||||
const notes = data.notes ?? existing.notes;
|
||||
const sortOrder = data.sort_order ?? existing.sort_order;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE api_providers SET name = ?, provider_type = ?, base_url = ?, api_key = ?, extra_env = ?, notes = ?, sort_order = ?, updated_at = ? WHERE id = ?'
|
||||
).run(name, providerType, baseUrl, apiKey, extraEnv, notes, sortOrder, now, id);
|
||||
`UPDATE api_providers SET name = ?, provider_type = ?, protocol = ?, base_url = ?, api_key = ?,
|
||||
extra_env = ?, headers_json = ?, env_overrides_json = ?, role_models_json = ?,
|
||||
notes = ?, sort_order = ?, updated_at = ? WHERE id = ?`
|
||||
).run(name, providerType, protocol, baseUrl, apiKey, extraEnv, headersJson, envOverridesJson, roleModelsJson, notes, sortOrder, now, id);
|
||||
|
||||
return getProvider(id);
|
||||
}
|
||||
@@ -1063,6 +1111,58 @@ export function deleteProvider(id: string): boolean {
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Provider Models ─────────────────────────────────────────────
|
||||
|
||||
export function getModelsForProvider(providerId: string): import('@/types').ProviderModel[] {
|
||||
const db = getDb();
|
||||
return db.prepare(
|
||||
'SELECT * FROM provider_models WHERE provider_id = ? AND enabled = 1 ORDER BY sort_order ASC, created_at ASC'
|
||||
).all(providerId) as import('@/types').ProviderModel[];
|
||||
}
|
||||
|
||||
export function upsertProviderModel(data: {
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
upstream_model_id?: string;
|
||||
display_name?: string;
|
||||
capabilities_json?: string;
|
||||
variants_json?: string;
|
||||
sort_order?: number;
|
||||
enabled?: number;
|
||||
}): void {
|
||||
const db = getDb();
|
||||
const id = crypto.randomBytes(16).toString('hex');
|
||||
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
||||
db.prepare(
|
||||
`INSERT INTO provider_models (id, provider_id, model_id, upstream_model_id, display_name, capabilities_json, variants_json, sort_order, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, model_id) DO UPDATE SET
|
||||
upstream_model_id = excluded.upstream_model_id,
|
||||
display_name = excluded.display_name,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
variants_json = excluded.variants_json,
|
||||
sort_order = excluded.sort_order,
|
||||
enabled = excluded.enabled`
|
||||
).run(
|
||||
id,
|
||||
data.provider_id,
|
||||
data.model_id,
|
||||
data.upstream_model_id || '',
|
||||
data.display_name || '',
|
||||
data.capabilities_json || '{}',
|
||||
data.variants_json || '{}',
|
||||
data.sort_order ?? 0,
|
||||
data.enabled ?? 1,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProviderModel(providerId: string, modelId: string): boolean {
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM provider_models WHERE provider_id = ? AND model_id = ?').run(providerId, modelId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function activateProvider(id: string): boolean {
|
||||
const db = getDb();
|
||||
const existing = getProvider(id);
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Provider Catalog — vendor presets, protocol definitions, and default model catalogs.
|
||||
*
|
||||
* This is the single source of truth for:
|
||||
* - Which protocol a vendor uses (anthropic, openai-compatible, bedrock, vertex, etc.)
|
||||
* - Default env overrides each vendor needs for Claude Code SDK
|
||||
* - Default model catalogs (role → upstream model id mapping)
|
||||
* - Auth key injection style (ANTHROPIC_API_KEY vs ANTHROPIC_AUTH_TOKEN)
|
||||
*/
|
||||
|
||||
// ── Protocol types ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Protocol describes how to talk to a provider's API.
|
||||
* This determines which SDK client to instantiate and which env vars to set.
|
||||
*/
|
||||
export type Protocol =
|
||||
| 'anthropic' // Native Anthropic API (official + third-party compatible)
|
||||
| 'openai-compatible' // OpenAI-compatible REST API
|
||||
| 'openrouter' // OpenRouter (OpenAI-compatible with extra headers)
|
||||
| 'bedrock' // AWS Bedrock (env-based auth, CLAUDE_CODE_USE_BEDROCK)
|
||||
| 'vertex' // Google Vertex AI (env-based auth, CLAUDE_CODE_USE_VERTEX)
|
||||
| 'google' // Google Generative AI (Gemini text)
|
||||
| 'gemini-image'; // Google Gemini image generation
|
||||
|
||||
/**
|
||||
* How the provider authenticates: which env var to inject the API key into.
|
||||
*/
|
||||
export type AuthStyle =
|
||||
| 'api_key' // ANTHROPIC_API_KEY
|
||||
| 'auth_token' // ANTHROPIC_AUTH_TOKEN
|
||||
| 'env_only' // No API key; auth via extra env (bedrock/vertex)
|
||||
| 'custom_header'; // API key in custom header (future)
|
||||
|
||||
/**
|
||||
* Model role — semantic purpose, maps to ANTHROPIC_DEFAULT_*, ANTHROPIC_MODEL, etc.
|
||||
*/
|
||||
export type ModelRole = 'default' | 'reasoning' | 'small' | 'haiku' | 'sonnet' | 'opus';
|
||||
|
||||
/**
|
||||
* A model entry in the catalog.
|
||||
*/
|
||||
export interface CatalogModel {
|
||||
/** Internal/UI model ID (what the user sees and what we pass to Claude Code) */
|
||||
modelId: string;
|
||||
/** Actual upstream model ID (what gets sent to the API) — if different from modelId */
|
||||
upstreamModelId?: string;
|
||||
/** Human-readable display name */
|
||||
displayName: string;
|
||||
/** Role mapping for Claude Code env vars */
|
||||
role?: ModelRole;
|
||||
/** Capabilities */
|
||||
capabilities?: {
|
||||
reasoning?: boolean;
|
||||
toolUse?: boolean;
|
||||
vision?: boolean;
|
||||
pdf?: boolean;
|
||||
contextWindow?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Role models map — maps semantic roles to model IDs.
|
||||
* Used to generate ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL, ANTHROPIC_DEFAULT_* env vars.
|
||||
*/
|
||||
export interface RoleModels {
|
||||
default?: string;
|
||||
reasoning?: string;
|
||||
small?: string;
|
||||
haiku?: string;
|
||||
sonnet?: string;
|
||||
opus?: string;
|
||||
}
|
||||
|
||||
// ── Vendor preset definition ────────────────────────────────────
|
||||
|
||||
export interface VendorPreset {
|
||||
/** Unique preset key (used as lookup key) */
|
||||
key: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Description (English) */
|
||||
description: string;
|
||||
/** Description (Chinese) */
|
||||
descriptionZh: string;
|
||||
/** Wire protocol */
|
||||
protocol: Protocol;
|
||||
/** Auth style */
|
||||
authStyle: AuthStyle;
|
||||
/** Default base URL (empty for bedrock/vertex) */
|
||||
baseUrl: string;
|
||||
/** Default env overrides for Claude Code SDK */
|
||||
defaultEnvOverrides: Record<string, string>;
|
||||
/** Default model catalog */
|
||||
defaultModels: CatalogModel[];
|
||||
/** Default role models mapping */
|
||||
defaultRoleModels?: RoleModels;
|
||||
/** Which fields the quick-connect form shows */
|
||||
fields: ('name' | 'api_key' | 'base_url' | 'env_overrides' | 'model_names')[];
|
||||
/** Category: chat (default) or media */
|
||||
category?: 'chat' | 'media';
|
||||
/** Icon key for UI */
|
||||
iconKey: string;
|
||||
}
|
||||
|
||||
// ── Default Anthropic models ────────────────────────────────────
|
||||
|
||||
const ANTHROPIC_DEFAULT_MODELS: CatalogModel[] = [
|
||||
{ modelId: 'sonnet', displayName: 'Sonnet 4.6', role: 'sonnet' },
|
||||
{ modelId: 'opus', displayName: 'Opus 4.6', role: 'opus' },
|
||||
{ modelId: 'haiku', displayName: 'Haiku 4.5', role: 'haiku' },
|
||||
];
|
||||
|
||||
// ── Vendor presets ──────────────────────────────────────────────
|
||||
|
||||
export const VENDOR_PRESETS: VendorPreset[] = [
|
||||
// ── Official Anthropic ──
|
||||
{
|
||||
key: 'anthropic-official',
|
||||
name: 'Anthropic',
|
||||
description: 'Official Anthropic API',
|
||||
descriptionZh: 'Anthropic 官方 API',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
defaultEnvOverrides: {},
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['api_key'],
|
||||
iconKey: 'anthropic',
|
||||
},
|
||||
|
||||
// ── Anthropic Third-party (generic) ──
|
||||
{
|
||||
key: 'anthropic-thirdparty',
|
||||
name: 'Anthropic Third-party API',
|
||||
description: 'Anthropic-compatible API — provide URL and Key',
|
||||
descriptionZh: 'Anthropic 兼容第三方 API — 填写地址和密钥',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: '',
|
||||
defaultEnvOverrides: { ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['name', 'api_key', 'base_url', 'env_overrides', 'model_names'],
|
||||
iconKey: 'anthropic',
|
||||
},
|
||||
|
||||
// ── OpenRouter ──
|
||||
{
|
||||
key: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
description: 'Use OpenRouter to access multiple models',
|
||||
descriptionZh: '通过 OpenRouter 访问多种模型',
|
||||
protocol: 'openrouter',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://openrouter.ai/api',
|
||||
defaultEnvOverrides: { ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['api_key'],
|
||||
iconKey: 'openrouter',
|
||||
},
|
||||
|
||||
// ── Zhipu GLM (China) ──
|
||||
{
|
||||
key: 'glm-cn',
|
||||
name: 'GLM (CN)',
|
||||
description: 'Zhipu GLM Code Plan — China region',
|
||||
descriptionZh: '智谱 GLM 编程套餐 — 中国区',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', upstreamModelId: 'sonnet', displayName: 'GLM-4.7', role: 'sonnet' },
|
||||
{ modelId: 'opus', upstreamModelId: 'opus', displayName: 'GLM-5', role: 'opus' },
|
||||
{ modelId: 'haiku', upstreamModelId: 'haiku', displayName: 'GLM-4.5-Air', role: 'haiku' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'zhipu',
|
||||
},
|
||||
|
||||
// ── Zhipu GLM (Global) ──
|
||||
{
|
||||
key: 'glm-global',
|
||||
name: 'GLM (Global)',
|
||||
description: 'Zhipu GLM Code Plan — Global region',
|
||||
descriptionZh: '智谱 GLM 编程套餐 — 国际区',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://api.z.ai/api/anthropic',
|
||||
defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', upstreamModelId: 'sonnet', displayName: 'GLM-4.7', role: 'sonnet' },
|
||||
{ modelId: 'opus', upstreamModelId: 'opus', displayName: 'GLM-5', role: 'opus' },
|
||||
{ modelId: 'haiku', upstreamModelId: 'haiku', displayName: 'GLM-4.5-Air', role: 'haiku' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'zhipu',
|
||||
},
|
||||
|
||||
// ── Kimi ──
|
||||
{
|
||||
key: 'kimi',
|
||||
name: 'Kimi Coding Plan',
|
||||
description: 'Kimi Coding Plan API',
|
||||
descriptionZh: 'Kimi 编程计划 API',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'auth_token',
|
||||
baseUrl: 'https://api.kimi.com/coding/',
|
||||
defaultEnvOverrides: { ANTHROPIC_AUTH_TOKEN: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', displayName: 'Kimi K2.5', role: 'default' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'kimi',
|
||||
},
|
||||
|
||||
// ── Moonshot ──
|
||||
{
|
||||
key: 'moonshot',
|
||||
name: 'Moonshot',
|
||||
description: 'Moonshot AI API',
|
||||
descriptionZh: '月之暗面 API',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://api.moonshot.cn/anthropic',
|
||||
defaultEnvOverrides: { ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', displayName: 'Kimi K2.5', role: 'default' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'moonshot',
|
||||
},
|
||||
|
||||
// ── MiniMax (China) ──
|
||||
{
|
||||
key: 'minimax-cn',
|
||||
name: 'MiniMax (CN)',
|
||||
description: 'MiniMax Code Plan — China region',
|
||||
descriptionZh: 'MiniMax 编程套餐 — 中国区',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
defaultEnvOverrides: {
|
||||
API_TIMEOUT_MS: '3000000',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
ANTHROPIC_API_KEY: '',
|
||||
},
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', displayName: 'MiniMax-M2.5', role: 'default' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'minimax',
|
||||
},
|
||||
|
||||
// ── MiniMax (Global) ──
|
||||
{
|
||||
key: 'minimax-global',
|
||||
name: 'MiniMax (Global)',
|
||||
description: 'MiniMax Code Plan — Global region',
|
||||
descriptionZh: 'MiniMax 编程套餐 — 国际区',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://api.minimax.io/anthropic',
|
||||
defaultEnvOverrides: {
|
||||
API_TIMEOUT_MS: '3000000',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
||||
ANTHROPIC_API_KEY: '',
|
||||
},
|
||||
defaultModels: [
|
||||
{ modelId: 'sonnet', displayName: 'MiniMax-M2.5', role: 'default' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'minimax',
|
||||
},
|
||||
|
||||
// ── Volcengine Ark ──
|
||||
{
|
||||
key: 'volcengine',
|
||||
name: 'Volcengine Ark',
|
||||
description: 'Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi',
|
||||
descriptionZh: '字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'auth_token',
|
||||
baseUrl: 'https://ark.cn-beijing.volces.com/api/coding',
|
||||
defaultEnvOverrides: { ANTHROPIC_AUTH_TOKEN: '' },
|
||||
defaultModels: [], // User must specify model_names
|
||||
fields: ['api_key', 'model_names'],
|
||||
iconKey: 'volcengine',
|
||||
},
|
||||
|
||||
// ── Aliyun Bailian ──
|
||||
{
|
||||
key: 'bailian',
|
||||
name: 'Aliyun Bailian',
|
||||
description: 'Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax',
|
||||
descriptionZh: '阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
defaultEnvOverrides: { ANTHROPIC_API_KEY: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'qwen3.5-plus', displayName: 'Qwen 3.5 Plus', role: 'default' },
|
||||
{ modelId: 'qwen3-coder-next', displayName: 'Qwen 3 Coder Next' },
|
||||
{ modelId: 'qwen3-coder-plus', displayName: 'Qwen 3 Coder Plus' },
|
||||
{ modelId: 'kimi-k2.5', displayName: 'Kimi K2.5' },
|
||||
{ modelId: 'glm-5', displayName: 'GLM-5' },
|
||||
{ modelId: 'glm-4.7', displayName: 'GLM-4.7' },
|
||||
{ modelId: 'MiniMax-M2.5', displayName: 'MiniMax-M2.5' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
iconKey: 'bailian',
|
||||
},
|
||||
|
||||
// ── AWS Bedrock ──
|
||||
{
|
||||
key: 'bedrock',
|
||||
name: 'AWS Bedrock',
|
||||
description: 'Amazon Bedrock — requires AWS credentials',
|
||||
descriptionZh: 'Amazon Bedrock — 需要 AWS 凭证',
|
||||
protocol: 'bedrock',
|
||||
authStyle: 'env_only',
|
||||
baseUrl: '',
|
||||
defaultEnvOverrides: {
|
||||
CLAUDE_CODE_USE_BEDROCK: '1',
|
||||
AWS_REGION: 'us-east-1',
|
||||
CLAUDE_CODE_SKIP_BEDROCK_AUTH: '1',
|
||||
},
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['env_overrides'],
|
||||
iconKey: 'bedrock',
|
||||
},
|
||||
|
||||
// ── Google Vertex AI ──
|
||||
{
|
||||
key: 'vertex',
|
||||
name: 'Google Vertex',
|
||||
description: 'Google Vertex AI — requires GCP credentials',
|
||||
descriptionZh: 'Google Vertex AI — 需要 GCP 凭证',
|
||||
protocol: 'vertex',
|
||||
authStyle: 'env_only',
|
||||
baseUrl: '',
|
||||
defaultEnvOverrides: {
|
||||
CLAUDE_CODE_USE_VERTEX: '1',
|
||||
CLOUD_ML_REGION: 'us-east5',
|
||||
CLAUDE_CODE_SKIP_VERTEX_AUTH: '1',
|
||||
},
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['env_overrides'],
|
||||
iconKey: 'google',
|
||||
},
|
||||
|
||||
// ── LiteLLM ──
|
||||
{
|
||||
key: 'litellm',
|
||||
name: 'LiteLLM',
|
||||
description: 'LiteLLM proxy — local or remote',
|
||||
descriptionZh: 'LiteLLM 代理 — 本地或远程',
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'http://localhost:4000',
|
||||
defaultEnvOverrides: {},
|
||||
defaultModels: ANTHROPIC_DEFAULT_MODELS,
|
||||
fields: ['api_key', 'base_url'],
|
||||
iconKey: 'server',
|
||||
},
|
||||
|
||||
// ── Google Gemini (Image) ──
|
||||
{
|
||||
key: 'gemini-image',
|
||||
name: 'Google Gemini (Image)',
|
||||
description: 'Nano Banana Pro — AI image generation by Google Gemini',
|
||||
descriptionZh: 'Nano Banana Pro — Google Gemini AI 图片生成',
|
||||
protocol: 'gemini-image',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
defaultEnvOverrides: { GEMINI_API_KEY: '' },
|
||||
defaultModels: [
|
||||
{ modelId: 'gemini-3.1-flash-image-preview', displayName: 'Nano Banana 2' },
|
||||
{ modelId: 'gemini-3-pro-image-preview', displayName: 'Nano Banana Pro' },
|
||||
{ modelId: 'gemini-2.5-flash-image', displayName: 'Nano Banana' },
|
||||
],
|
||||
fields: ['api_key'],
|
||||
category: 'media',
|
||||
iconKey: 'google',
|
||||
},
|
||||
|
||||
// ── Custom API (OpenAI-compatible) ──
|
||||
{
|
||||
key: 'custom-openai',
|
||||
name: 'Custom API (OpenAI-compatible)',
|
||||
description: 'OpenAI-compatible custom endpoint',
|
||||
descriptionZh: '自定义 OpenAI 兼容 API 端点',
|
||||
protocol: 'openai-compatible',
|
||||
authStyle: 'api_key',
|
||||
baseUrl: '',
|
||||
defaultEnvOverrides: {},
|
||||
defaultModels: [],
|
||||
fields: ['name', 'api_key', 'base_url', 'env_overrides'],
|
||||
iconKey: 'server',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
// ── Lookup helpers ──────────────────────────────────────────────
|
||||
|
||||
/** Get a preset by key. */
|
||||
export function getPreset(key: string): VendorPreset | undefined {
|
||||
return VENDOR_PRESETS.find(p => p.key === key);
|
||||
}
|
||||
|
||||
/** Get all presets for a given category (defaults to 'chat'). */
|
||||
export function getPresetsByCategory(category: 'chat' | 'media' = 'chat'): VendorPreset[] {
|
||||
return VENDOR_PRESETS.filter(p => (p.category || 'chat') === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the protocol from a legacy provider_type.
|
||||
* Used during migration from the old system.
|
||||
*/
|
||||
export function inferProtocolFromLegacy(
|
||||
providerType: string,
|
||||
baseUrl: string,
|
||||
): Protocol {
|
||||
// Direct type mappings
|
||||
if (providerType === 'anthropic') return 'anthropic';
|
||||
if (providerType === 'openrouter') return 'openrouter';
|
||||
if (providerType === 'bedrock') return 'bedrock';
|
||||
if (providerType === 'vertex') return 'vertex';
|
||||
if (providerType === 'gemini-image') return 'gemini-image';
|
||||
|
||||
// For 'custom' type, check if the base_url matches a known Anthropic-compatible vendor
|
||||
if (providerType === 'custom') {
|
||||
const anthropicUrls = [
|
||||
'bigmodel.cn', 'z.ai', // GLM
|
||||
'kimi.com', 'moonshot.cn', 'moonshot.ai', // Kimi/Moonshot
|
||||
'minimaxi.com', 'minimax.io', // MiniMax
|
||||
'volces.com', 'volcengine.com', // Volcengine
|
||||
'dashscope.aliyuncs.com', // Bailian
|
||||
];
|
||||
const urlLower = baseUrl.toLowerCase();
|
||||
if (anthropicUrls.some(u => urlLower.includes(u))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
// Check if URL contains 'anthropic' in the path
|
||||
if (urlLower.includes('/anthropic')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
// Default custom → openai-compatible
|
||||
return 'openai-compatible';
|
||||
}
|
||||
|
||||
return 'openai-compatible';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the auth style from a legacy provider.
|
||||
* Checks extra_env to determine if it uses AUTH_TOKEN vs API_KEY.
|
||||
*/
|
||||
export function inferAuthStyleFromLegacy(
|
||||
providerType: string,
|
||||
extraEnv: string,
|
||||
): AuthStyle {
|
||||
if (providerType === 'bedrock' || providerType === 'vertex') return 'env_only';
|
||||
|
||||
try {
|
||||
const env = JSON.parse(extraEnv || '{}');
|
||||
if ('ANTHROPIC_AUTH_TOKEN' in env) return 'auth_token';
|
||||
} catch { /* fallthrough */ }
|
||||
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching vendor preset for a legacy provider.
|
||||
* Matches by base_url first, then by provider_type.
|
||||
*/
|
||||
export function findPresetForLegacy(baseUrl: string, providerType: string): VendorPreset | undefined {
|
||||
// Exact base_url match (most specific)
|
||||
if (baseUrl) {
|
||||
const match = VENDOR_PRESETS.find(p => p.baseUrl === baseUrl);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
// Type-based fallback
|
||||
if (providerType === 'bedrock') return VENDOR_PRESETS.find(p => p.key === 'bedrock');
|
||||
if (providerType === 'vertex') return VENDOR_PRESETS.find(p => p.key === 'vertex');
|
||||
if (providerType === 'openrouter') return VENDOR_PRESETS.find(p => p.key === 'openrouter');
|
||||
if (providerType === 'gemini-image') return VENDOR_PRESETS.find(p => p.key === 'gemini-image');
|
||||
if (providerType === 'anthropic' && baseUrl === 'https://api.anthropic.com') {
|
||||
return VENDOR_PRESETS.find(p => p.key === 'anthropic-official');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default models for a provider based on its catalog preset.
|
||||
* If the provider has a matching preset, returns the preset's defaultModels.
|
||||
* Otherwise returns the Anthropic default models.
|
||||
*/
|
||||
export function getDefaultModelsForProvider(
|
||||
protocol: Protocol,
|
||||
baseUrl: string,
|
||||
): CatalogModel[] {
|
||||
// Try to find a preset by base_url
|
||||
const preset = VENDOR_PRESETS.find(p => p.baseUrl && p.baseUrl === baseUrl);
|
||||
if (preset && preset.defaultModels.length > 0) {
|
||||
return preset.defaultModels;
|
||||
}
|
||||
|
||||
// Protocol-based defaults
|
||||
if (protocol === 'anthropic' || protocol === 'openrouter' || protocol === 'bedrock' || protocol === 'vertex') {
|
||||
return ANTHROPIC_DEFAULT_MODELS;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Provider Resolver — unified provider/model resolution for all consumers.
|
||||
*
|
||||
* Every entry point (chat, bridge, onboarding, check-in, media plan) calls
|
||||
* this module instead of doing its own provider resolution. This guarantees
|
||||
* the same provider+model+protocol+env for the same inputs everywhere.
|
||||
*/
|
||||
|
||||
import type { ApiProvider } from '@/types';
|
||||
import {
|
||||
type Protocol,
|
||||
type AuthStyle,
|
||||
type CatalogModel,
|
||||
type RoleModels,
|
||||
inferProtocolFromLegacy,
|
||||
inferAuthStyleFromLegacy,
|
||||
getDefaultModelsForProvider,
|
||||
findPresetForLegacy,
|
||||
} from './provider-catalog';
|
||||
import {
|
||||
getProvider,
|
||||
getDefaultProviderId,
|
||||
getActiveProvider,
|
||||
getSetting,
|
||||
getModelsForProvider,
|
||||
} from './db';
|
||||
|
||||
// ── Resolution result ───────────────────────────────────────────
|
||||
|
||||
export interface ResolvedProvider {
|
||||
/** The DB provider record (undefined = use env vars) */
|
||||
provider: ApiProvider | undefined;
|
||||
/** Wire protocol */
|
||||
protocol: Protocol;
|
||||
/** Auth style */
|
||||
authStyle: AuthStyle;
|
||||
/** Resolved model ID (internal/UI model ID) */
|
||||
model: string | undefined;
|
||||
/** Upstream model ID (what actually gets sent to the API — may differ from model) */
|
||||
upstreamModel: string | undefined;
|
||||
/** Display name for the model */
|
||||
modelDisplayName: string | undefined;
|
||||
/** Extra headers (parsed from headers_json or empty) */
|
||||
headers: Record<string, string>;
|
||||
/** Environment overrides (parsed from env_overrides_json / extra_env) */
|
||||
envOverrides: Record<string, string>;
|
||||
/** Role models mapping (parsed from role_models_json or inferred from catalog) */
|
||||
roleModels: RoleModels;
|
||||
/** Whether the provider has usable credentials */
|
||||
hasCredentials: boolean;
|
||||
/** Available models for this provider */
|
||||
availableModels: CatalogModel[];
|
||||
/** Settings sources for Claude Code SDK */
|
||||
settingSources: string[];
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Explicit provider ID from request (highest priority) */
|
||||
providerId?: string;
|
||||
/** Session's stored provider ID */
|
||||
sessionProviderId?: string;
|
||||
/** Requested model */
|
||||
model?: string;
|
||||
/** Session's stored model */
|
||||
sessionModel?: string;
|
||||
/** Use case — affects which role model to pick */
|
||||
useCase?: 'default' | 'reasoning' | 'small';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider + model for any consumer.
|
||||
*
|
||||
* Priority chain (same everywhere):
|
||||
* 1. Explicit providerId in request
|
||||
* 2. Session's provider_id
|
||||
* 3. Global default_provider_id
|
||||
* 4. Environment variables (resolvedProvider = undefined)
|
||||
*
|
||||
* Special value 'env' = use environment variables (skip DB lookup).
|
||||
*/
|
||||
export function resolveProvider(opts: ResolveOptions = {}): ResolvedProvider {
|
||||
const effectiveProviderId = opts.providerId || opts.sessionProviderId || '';
|
||||
|
||||
let provider: ApiProvider | undefined;
|
||||
|
||||
if (effectiveProviderId && effectiveProviderId !== 'env') {
|
||||
// Explicit provider — look it up
|
||||
provider = getProvider(effectiveProviderId);
|
||||
if (!provider) {
|
||||
// Requested provider not found, fall back to default
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (defaultId) provider = getProvider(defaultId);
|
||||
}
|
||||
} else if (!effectiveProviderId) {
|
||||
// No provider specified — use global default
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (defaultId) provider = getProvider(defaultId);
|
||||
}
|
||||
// effectiveProviderId === 'env' → provider stays undefined
|
||||
|
||||
return buildResolution(provider, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve provider for the Claude Code SDK subprocess (used by claude-client.ts).
|
||||
* Uses the same resolution chain but also checks getActiveProvider() for backwards compat.
|
||||
*
|
||||
* Important: if resolveProvider() intentionally returned provider=undefined (e.g. user
|
||||
* selected 'env'), we respect that and do NOT fall back to getActiveProvider().
|
||||
*/
|
||||
export function resolveForClaudeCode(
|
||||
explicitProvider?: ApiProvider,
|
||||
opts: ResolveOptions = {},
|
||||
): ResolvedProvider {
|
||||
if (explicitProvider) {
|
||||
return buildResolution(explicitProvider, opts);
|
||||
}
|
||||
const resolved = resolveProvider(opts);
|
||||
// Only fall back to getActiveProvider() when NO provider resolution was attempted
|
||||
// (i.e. no explicit ID, no session ID, no global default). If the resolver ran and
|
||||
// returned provider=undefined (env mode), respect that decision.
|
||||
if (!resolved.provider && !opts.providerId && !opts.sessionProviderId) {
|
||||
const defaultId = getDefaultProviderId();
|
||||
if (!defaultId) {
|
||||
// No default configured either — last resort backwards compat
|
||||
const active = getActiveProvider();
|
||||
if (active) return buildResolution(active, opts);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ── Claude Code env builder ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build environment variables for a Claude Code SDK subprocess.
|
||||
* Replaces the inline env-building logic in claude-client.ts.
|
||||
*
|
||||
* @param baseEnv - Process environment (usually { ...process.env })
|
||||
* @param resolved - Output from resolveProvider/resolveForClaudeCode
|
||||
* @returns Clean env suitable for the SDK subprocess
|
||||
*/
|
||||
export function toClaudeCodeEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
resolved: ResolvedProvider,
|
||||
): Record<string, string> {
|
||||
const env = { ...baseEnv };
|
||||
|
||||
// Managed env vars that must be cleaned when switching providers to prevent leaks
|
||||
const MANAGED_ENV_KEYS = new Set([
|
||||
'API_TIMEOUT_MS',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
'ENABLE_TOOL_SEARCH',
|
||||
'AWS_REGION',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CLOUD_ML_REGION',
|
||||
'ANTHROPIC_PROJECT_ID',
|
||||
'GEMINI_API_KEY',
|
||||
]);
|
||||
|
||||
if (resolved.provider && resolved.hasCredentials) {
|
||||
// Clear all ANTHROPIC_* variables AND managed env vars to prevent cross-provider leaks
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('ANTHROPIC_') || MANAGED_ENV_KEYS.has(key)) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Inject auth based on style
|
||||
const apiKey = resolved.provider.api_key;
|
||||
if (apiKey) {
|
||||
switch (resolved.authStyle) {
|
||||
case 'auth_token':
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
break;
|
||||
case 'api_key':
|
||||
default:
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Inject base URL
|
||||
if (resolved.provider.base_url) {
|
||||
env.ANTHROPIC_BASE_URL = resolved.provider.base_url;
|
||||
}
|
||||
|
||||
// Inject role models as env vars
|
||||
if (resolved.roleModels.default) {
|
||||
env.ANTHROPIC_MODEL = resolved.roleModels.default;
|
||||
}
|
||||
if (resolved.roleModels.reasoning) {
|
||||
env.ANTHROPIC_REASONING_MODEL = resolved.roleModels.reasoning;
|
||||
}
|
||||
if (resolved.roleModels.small) {
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL = resolved.roleModels.small;
|
||||
}
|
||||
if (resolved.roleModels.haiku) {
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.roleModels.haiku;
|
||||
}
|
||||
if (resolved.roleModels.sonnet) {
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.roleModels.sonnet;
|
||||
}
|
||||
if (resolved.roleModels.opus) {
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.roleModels.opus;
|
||||
}
|
||||
|
||||
// Inject extra headers
|
||||
for (const [k, v] of Object.entries(resolved.headers)) {
|
||||
if (v) env[k] = v;
|
||||
}
|
||||
|
||||
// Inject env overrides (empty string = delete).
|
||||
// Skip auth-related keys — they were already correctly injected above based on authStyle.
|
||||
// Legacy extra_env often contains placeholder entries like {"ANTHROPIC_AUTH_TOKEN":""} or
|
||||
// {"ANTHROPIC_API_KEY":""} that would delete the freshly-injected credentials.
|
||||
const AUTH_ENV_KEYS = new Set([
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
]);
|
||||
for (const [key, value] of Object.entries(resolved.envOverrides)) {
|
||||
if (AUTH_ENV_KEYS.has(key)) continue; // already handled by auth injection
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') {
|
||||
delete env[key];
|
||||
} else {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!resolved.provider) {
|
||||
// No provider — check legacy DB settings, then fall back to existing env
|
||||
const appToken = getSetting('anthropic_auth_token');
|
||||
const appBaseUrl = getSetting('anthropic_base_url');
|
||||
if (appToken) env.ANTHROPIC_AUTH_TOKEN = appToken;
|
||||
if (appBaseUrl) env.ANTHROPIC_BASE_URL = appBaseUrl;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
// ── AI SDK config builder ───────────────────────────────────────
|
||||
|
||||
export interface AiSdkConfig {
|
||||
/** Which AI SDK factory to use */
|
||||
sdkType: 'anthropic' | 'openai' | 'google' | 'bedrock' | 'vertex';
|
||||
/** API key to pass to the SDK */
|
||||
apiKey: string | undefined;
|
||||
/** Base URL to pass to the SDK */
|
||||
baseUrl: string | undefined;
|
||||
/** The model ID to request (upstream/API model ID) */
|
||||
modelId: string;
|
||||
/** Extra headers to pass to the SDK client */
|
||||
headers: Record<string, string>;
|
||||
/** Extra env vars to inject into process.env before SDK call */
|
||||
processEnvInjections: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build configuration for the Vercel AI SDK (used by text-generator.ts).
|
||||
* Replaces the inline provider-type branching in text-generator.ts.
|
||||
*/
|
||||
export function toAiSdkConfig(
|
||||
resolved: ResolvedProvider,
|
||||
modelOverride?: string,
|
||||
): AiSdkConfig {
|
||||
// Resolve the upstream model ID (the actual API model name).
|
||||
// If modelOverride is given (from caller), check if it maps to a different upstream ID
|
||||
// in the provider's available models. This prevents callers from accidentally passing
|
||||
// the internal/UI model ID when the upstream API expects a different name.
|
||||
let modelId: string;
|
||||
if (modelOverride) {
|
||||
const catalogEntry = resolved.availableModels.find(m => m.modelId === modelOverride);
|
||||
modelId = catalogEntry?.upstreamModelId || modelOverride;
|
||||
} else {
|
||||
modelId = resolved.upstreamModel || resolved.model || 'claude-sonnet-4-20250514';
|
||||
}
|
||||
const provider = resolved.provider;
|
||||
const protocol = resolved.protocol;
|
||||
const processEnvInjections: Record<string, string> = {};
|
||||
|
||||
// For bedrock/vertex, inject env overrides into process.env
|
||||
if (protocol === 'bedrock' || protocol === 'vertex') {
|
||||
for (const [k, v] of Object.entries(resolved.envOverrides)) {
|
||||
if (typeof v === 'string' && v !== '') {
|
||||
processEnvInjections[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers = resolved.headers;
|
||||
|
||||
switch (protocol) {
|
||||
case 'anthropic':
|
||||
return {
|
||||
sdkType: 'anthropic',
|
||||
apiKey: provider?.api_key || undefined,
|
||||
baseUrl: provider?.base_url || undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
case 'openrouter':
|
||||
return {
|
||||
sdkType: 'openai',
|
||||
apiKey: provider?.api_key || undefined,
|
||||
baseUrl: provider?.base_url || 'https://openrouter.ai/api/v1',
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
case 'openai-compatible':
|
||||
return {
|
||||
sdkType: 'openai',
|
||||
apiKey: provider?.api_key || undefined,
|
||||
baseUrl: provider?.base_url || undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
case 'bedrock':
|
||||
// If base_url is set, route through OpenAI-compatible proxy; otherwise use native SDK
|
||||
if (provider?.base_url) {
|
||||
return {
|
||||
sdkType: 'openai',
|
||||
apiKey: provider.api_key || 'dummy',
|
||||
baseUrl: provider.base_url,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sdkType: 'bedrock',
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
case 'vertex':
|
||||
// If base_url is set, route through OpenAI-compatible proxy; otherwise use native SDK
|
||||
if (provider?.base_url) {
|
||||
return {
|
||||
sdkType: 'openai',
|
||||
apiKey: provider.api_key || 'dummy',
|
||||
baseUrl: provider.base_url,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
}
|
||||
return {
|
||||
sdkType: 'vertex',
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
case 'google':
|
||||
case 'gemini-image':
|
||||
return {
|
||||
sdkType: 'google',
|
||||
apiKey: provider?.api_key || undefined,
|
||||
baseUrl: provider?.base_url || undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
sdkType: 'anthropic',
|
||||
apiKey: provider?.api_key || undefined,
|
||||
baseUrl: provider?.base_url || undefined,
|
||||
modelId,
|
||||
headers,
|
||||
processEnvInjections,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
function buildResolution(
|
||||
provider: ApiProvider | undefined,
|
||||
opts: ResolveOptions,
|
||||
): ResolvedProvider {
|
||||
if (!provider) {
|
||||
// Environment-based provider (no DB record) — credentials come from shell env or legacy DB settings
|
||||
const envHasCredentials = !!(
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.ANTHROPIC_AUTH_TOKEN ||
|
||||
getSetting('anthropic_auth_token')
|
||||
);
|
||||
const model = opts.model || opts.sessionModel || getSetting('default_model') || undefined;
|
||||
return {
|
||||
provider: undefined,
|
||||
protocol: 'anthropic',
|
||||
authStyle: 'api_key',
|
||||
model,
|
||||
upstreamModel: model, // identity mapping for env mode
|
||||
modelDisplayName: undefined,
|
||||
headers: {},
|
||||
envOverrides: {},
|
||||
roleModels: {},
|
||||
hasCredentials: envHasCredentials,
|
||||
availableModels: [],
|
||||
settingSources: ['user', 'project', 'local'],
|
||||
};
|
||||
}
|
||||
|
||||
// Determine protocol (new field or infer from legacy)
|
||||
const protocol = inferProtocolFromProvider(provider);
|
||||
const authStyle = inferAuthStyleFromProvider(provider);
|
||||
|
||||
// Parse JSON fields
|
||||
const headers = safeParseJson(provider.headers_json);
|
||||
const envOverrides = safeParseJson(provider.env_overrides_json || provider.extra_env);
|
||||
const roleModels = safeParseJson(provider.role_models_json) as RoleModels;
|
||||
|
||||
// Get available models: DB provider_models take priority, then catalog defaults
|
||||
let availableModels = getDefaultModelsForProvider(protocol, provider.base_url);
|
||||
try {
|
||||
const dbModels = getModelsForProvider(provider.id);
|
||||
if (dbModels.length > 0) {
|
||||
// Convert DB rows to CatalogModel and merge (DB models override catalog by modelId)
|
||||
const dbCatalog: CatalogModel[] = dbModels.map(m => ({
|
||||
modelId: m.model_id,
|
||||
upstreamModelId: m.upstream_model_id || undefined,
|
||||
displayName: m.display_name || m.model_id,
|
||||
capabilities: safeParseCapabilities(m.capabilities_json),
|
||||
}));
|
||||
// Merge: DB models first, then catalog models not already in DB
|
||||
const dbIds = new Set(dbCatalog.map(m => m.modelId));
|
||||
availableModels = [...dbCatalog, ...availableModels.filter(m => !dbIds.has(m.modelId))];
|
||||
}
|
||||
} catch { /* provider_models table may not exist in old DBs */ }
|
||||
|
||||
// Resolve model — priority:
|
||||
// 1. Explicit request model (opts.model)
|
||||
// 2. Session's stored model (opts.sessionModel)
|
||||
// 3. Provider's roleModels.default (configured per-provider default, e.g. "ark-code-latest")
|
||||
// 4. Global default_model setting
|
||||
const requestedModel = opts.model || opts.sessionModel || roleModels.default || getSetting('default_model') || undefined;
|
||||
let model = requestedModel;
|
||||
let upstreamModel: string | undefined;
|
||||
let modelDisplayName: string | undefined;
|
||||
|
||||
// If a use case is specified, check role models for that use case
|
||||
if (opts.useCase && opts.useCase !== 'default' && roleModels[opts.useCase]) {
|
||||
model = roleModels[opts.useCase];
|
||||
}
|
||||
|
||||
// Find display name and upstream model ID from catalog
|
||||
if (model && availableModels.length > 0) {
|
||||
const catalogEntry = availableModels.find(m => m.modelId === model);
|
||||
if (catalogEntry) {
|
||||
modelDisplayName = catalogEntry.displayName;
|
||||
// upstreamModelId is what actually gets sent to the API (may differ from the UI model ID)
|
||||
upstreamModel = catalogEntry.upstreamModelId || model;
|
||||
}
|
||||
}
|
||||
// If no catalog entry, upstream = model (identity mapping)
|
||||
if (!upstreamModel && model) {
|
||||
upstreamModel = model;
|
||||
}
|
||||
|
||||
// Has credentials?
|
||||
const hasCredentials = !!(provider.api_key) || authStyle === 'env_only';
|
||||
|
||||
// Settings sources — skip 'user' when a provider is configured to avoid
|
||||
// ~/.claude/settings.json env overrides conflicting with provider config
|
||||
const settingSources = hasCredentials
|
||||
? ['project', 'local']
|
||||
: ['user', 'project', 'local'];
|
||||
|
||||
return {
|
||||
provider,
|
||||
protocol,
|
||||
authStyle,
|
||||
model,
|
||||
upstreamModel,
|
||||
modelDisplayName,
|
||||
headers,
|
||||
envOverrides,
|
||||
roleModels,
|
||||
hasCredentials,
|
||||
availableModels,
|
||||
settingSources,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine protocol from a provider record.
|
||||
* Uses the new `protocol` field if present, otherwise infers from legacy fields.
|
||||
*/
|
||||
function inferProtocolFromProvider(provider: ApiProvider): Protocol {
|
||||
// New field takes precedence
|
||||
if (provider.protocol) {
|
||||
return provider.protocol as Protocol;
|
||||
}
|
||||
// Legacy inference
|
||||
return inferProtocolFromLegacy(provider.provider_type, provider.base_url);
|
||||
}
|
||||
|
||||
function inferAuthStyleFromProvider(provider: ApiProvider): AuthStyle {
|
||||
// Check preset match first
|
||||
const preset = findPresetForLegacy(provider.base_url, provider.provider_type);
|
||||
if (preset) return preset.authStyle;
|
||||
|
||||
return inferAuthStyleFromLegacy(provider.provider_type, provider.extra_env);
|
||||
}
|
||||
|
||||
function safeParseJson(json: string | undefined | null): Record<string, string> {
|
||||
if (!json) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed === 'object' && parsed !== null) return parsed;
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
function safeParseCapabilities(json: string | undefined | null): CatalogModel['capabilities'] {
|
||||
if (!json) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed === 'object' && parsed !== null) return parsed;
|
||||
} catch { /* ignore */ }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ApiProvider now includes protocol, headers_json, env_overrides_json, role_models_json
|
||||
// directly — no type augmentation needed.
|
||||
+67
-73
@@ -2,8 +2,9 @@ import { streamText } from 'ai';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { getDb } from '@/lib/db';
|
||||
import type { ApiProvider } from '@/types';
|
||||
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
|
||||
import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
|
||||
import { resolveProvider as resolveProviderUnified, toAiSdkConfig } from './provider-resolver';
|
||||
|
||||
export interface StreamTextParams {
|
||||
providerId: string;
|
||||
@@ -14,87 +15,80 @@ export interface StreamTextParams {
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider from DB by ID. Falls back to the default provider if not found.
|
||||
*/
|
||||
function resolveProvider(providerId: string): ApiProvider | undefined {
|
||||
const db = getDb();
|
||||
|
||||
// Try the specified provider first
|
||||
let provider = db.prepare('SELECT * FROM api_providers WHERE id = ?').get(providerId) as ApiProvider | undefined;
|
||||
if (provider && provider.api_key) return provider;
|
||||
|
||||
// Fallback: get the default provider
|
||||
const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
|
||||
if (defaultId?.value) {
|
||||
provider = db.prepare('SELECT * FROM api_providers WHERE id = ?').get(defaultId.value) as ApiProvider | undefined;
|
||||
if (provider && provider.api_key) return provider;
|
||||
}
|
||||
|
||||
// Last resort: any provider with an API key (excluding gemini-image)
|
||||
return db.prepare(
|
||||
"SELECT * FROM api_providers WHERE api_key != '' AND provider_type != 'gemini-image' ORDER BY sort_order ASC LIMIT 1"
|
||||
).get() as ApiProvider | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AI SDK language model instance from a provider config.
|
||||
*/
|
||||
function createLanguageModel(provider: ApiProvider, modelId: string) {
|
||||
const providerType = provider.provider_type;
|
||||
|
||||
if (providerType === 'anthropic') {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: provider.api_key,
|
||||
baseURL: provider.base_url || undefined,
|
||||
});
|
||||
return anthropic(modelId);
|
||||
}
|
||||
|
||||
if (providerType === 'gemini-image') {
|
||||
// Gemini providers can also do text generation
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey: provider.api_key,
|
||||
baseURL: provider.base_url || undefined,
|
||||
});
|
||||
return google(modelId);
|
||||
}
|
||||
|
||||
if (providerType === 'openrouter') {
|
||||
// OpenRouter uses OpenAI-compatible API
|
||||
const openrouter = createOpenAI({
|
||||
apiKey: provider.api_key,
|
||||
baseURL: provider.base_url || 'https://openrouter.ai/api/v1',
|
||||
});
|
||||
return openrouter(modelId);
|
||||
}
|
||||
|
||||
// custom, bedrock, vertex — use OpenAI-compatible endpoint
|
||||
if (provider.base_url) {
|
||||
const custom = createOpenAI({
|
||||
apiKey: provider.api_key,
|
||||
baseURL: provider.base_url,
|
||||
});
|
||||
return custom(modelId);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported provider type "${providerType}" or missing base_url for custom provider.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream text from the user's current provider.
|
||||
* Returns an async iterable of text chunks.
|
||||
*
|
||||
* Provider resolution is fully delegated to the unified resolver.
|
||||
* No fallback logic here — the resolver's chain (explicit → session → global default → env)
|
||||
* is the single source of truth, matching the Claude Code SDK path.
|
||||
*/
|
||||
export async function* streamTextFromProvider(params: StreamTextParams): AsyncIterable<string> {
|
||||
const provider = resolveProvider(params.providerId);
|
||||
if (!provider) {
|
||||
const resolved = resolveProviderUnified({ providerId: params.providerId });
|
||||
|
||||
if (!resolved.hasCredentials && !resolved.provider) {
|
||||
throw new Error('No text generation provider available. Please configure a provider in Settings.');
|
||||
}
|
||||
|
||||
const model = createLanguageModel(provider, params.model);
|
||||
const config = toAiSdkConfig(resolved, params.model);
|
||||
|
||||
// Inject process env if needed (bedrock/vertex)
|
||||
for (const [k, v] of Object.entries(config.processEnvInjections)) {
|
||||
process.env[k] = v;
|
||||
}
|
||||
|
||||
// Build headers object for SDK clients (only if non-empty)
|
||||
const hasHeaders = config.headers && Object.keys(config.headers).length > 0;
|
||||
|
||||
let model;
|
||||
switch (config.sdkType) {
|
||||
case 'anthropic': {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
...(hasHeaders ? { headers: config.headers } : {}),
|
||||
});
|
||||
model = anthropic(config.modelId);
|
||||
break;
|
||||
}
|
||||
case 'openai': {
|
||||
const openai = createOpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
...(hasHeaders ? { headers: config.headers } : {}),
|
||||
});
|
||||
model = openai(config.modelId);
|
||||
break;
|
||||
}
|
||||
case 'google': {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl,
|
||||
...(hasHeaders ? { headers: config.headers } : {}),
|
||||
});
|
||||
model = google(config.modelId);
|
||||
break;
|
||||
}
|
||||
case 'bedrock': {
|
||||
// Auth via process.env (AWS_REGION, AWS_ACCESS_KEY_ID, etc.) — already injected above
|
||||
const bedrock = createAmazonBedrock({
|
||||
...(hasHeaders ? { headers: config.headers } : {}),
|
||||
});
|
||||
model = bedrock(config.modelId);
|
||||
break;
|
||||
}
|
||||
case 'vertex': {
|
||||
// Anthropic-on-Vertex: auth via process.env (CLOUD_ML_REGION, GOOGLE_APPLICATION_CREDENTIALS, etc.)
|
||||
const vertex = createVertexAnthropic({
|
||||
...(hasHeaders ? { headers: config.headers } : {}),
|
||||
});
|
||||
model = vertex(config.modelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
model: model!,
|
||||
system: params.system,
|
||||
prompt: params.prompt,
|
||||
maxOutputTokens: params.maxTokens || 4096,
|
||||
|
||||
+40
-4
@@ -108,12 +108,20 @@ export interface Setting {
|
||||
export interface ApiProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_type: string; // 'anthropic' | 'openrouter' | 'bedrock' | 'vertex' | 'custom'
|
||||
provider_type: string; // legacy: 'anthropic' | 'openrouter' | 'bedrock' | 'vertex' | 'custom'
|
||||
/** Wire protocol — new field, takes precedence over provider_type for dispatch */
|
||||
protocol: string; // 'anthropic' | 'openai-compatible' | 'openrouter' | 'bedrock' | 'vertex' | 'google' | 'gemini-image'
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
is_active: number; // SQLite boolean: 0 or 1
|
||||
sort_order: number;
|
||||
extra_env: string; // JSON string of Record<string, string>
|
||||
extra_env: string; // JSON string of Record<string, string> (legacy, prefer env_overrides_json)
|
||||
/** Extra headers to send with API requests — JSON string of Record<string, string> */
|
||||
headers_json: string;
|
||||
/** Environment overrides for Claude Code SDK subprocess — JSON string of Record<string, string> */
|
||||
env_overrides_json: string;
|
||||
/** Semantic model role mapping — JSON string of { default?, reasoning?, small?, haiku?, sonnet?, opus? } */
|
||||
role_models_json: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -124,31 +132,55 @@ export interface ProviderModelGroup {
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
models: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
value: string; // internal/UI model ID
|
||||
label: string; // display name
|
||||
upstreamModelId?: string; // actual API model ID (if different from value)
|
||||
contextWindow?: number;
|
||||
description?: string;
|
||||
supportsEffort?: boolean;
|
||||
supportedEffortLevels?: string[];
|
||||
supportsAdaptiveThinking?: boolean;
|
||||
capabilities?: Record<string, unknown>;
|
||||
variants?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
upstream_model_id: string;
|
||||
display_name: string;
|
||||
capabilities_json: string;
|
||||
variants_json: string;
|
||||
sort_order: number;
|
||||
enabled: number; // SQLite boolean
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface UpdateProviderRequest {
|
||||
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;
|
||||
sort_order?: number;
|
||||
}
|
||||
@@ -821,6 +853,10 @@ export interface ClaudeStreamOptions {
|
||||
imageAgentMode?: boolean;
|
||||
toolTimeoutSeconds?: number;
|
||||
provider?: ApiProvider;
|
||||
/** Explicit provider ID (e.g. 'env') — passed to resolveForClaudeCode */
|
||||
providerId?: string;
|
||||
/** Session's stored provider ID — passed to resolveForClaudeCode */
|
||||
sessionProviderId?: string;
|
||||
/** Recent conversation history from DB — used as fallback context when SDK resume is unavailable or fails */
|
||||
conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
onRuntimeStatusChange?: (status: string) => void;
|
||||
|
||||
Reference in new issue
Block a user