Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
651123db1f | ||
|
|
34246635fb | ||
|
|
43ac6dba75 | ||
|
|
80a00acc2c | ||
|
|
eed77e6579 | ||
|
|
b280c740a6 | ||
|
|
2ff5710329 | ||
|
|
d6f5130c20 | ||
|
|
d32a2a1329 | ||
|
|
fbcd928f7f | ||
|
|
77083d769b | ||
|
|
b66633ea4d | ||
|
|
51191d6132 | ||
|
|
6b2121da12 | ||
|
|
c207cdbdcc | ||
|
|
a00b7928de | ||
|
|
12dd3755c6 | ||
|
|
114f772a4a | ||
|
|
7187fc007a | ||
|
|
0ed50ccfe7 |
24
.env.example
24
.env.example
@@ -225,6 +225,30 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|||||||
# GOOGLE_CLOUD_PROJECT=your-gcp-project-id
|
# GOOGLE_CLOUD_PROJECT=your-gcp-project-id
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Option 9: NVIDIA NIM
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# NVIDIA NIM provides hosted inference endpoints for NVIDIA models.
|
||||||
|
# Get your API key from https://build.nvidia.com/
|
||||||
|
#
|
||||||
|
# CLAUDE_CODE_USE_OPENAI=1
|
||||||
|
# NVIDIA_API_KEY=nvapi-your-key-here
|
||||||
|
# OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
# OPENAI_MODEL=nvidia/llama-3.1-nemotron-70b-instruct
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Option 10: MiniMax
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MiniMax API provides text generation models.
|
||||||
|
# Get your API key from https://platform.minimax.io/
|
||||||
|
#
|
||||||
|
# CLAUDE_CODE_USE_OPENAI=1
|
||||||
|
# MINIMAX_API_KEY=your-minimax-key-here
|
||||||
|
# OPENAI_BASE_URL=https://api.minimax.io/v1
|
||||||
|
# OPENAI_MODEL=MiniMax-M2.5
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# OPTIONAL TUNING
|
# OPTIONAL TUNING
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-please:
|
release-please:
|
||||||
|
if: ${{ github.repository == 'Gitlawb/openclaude' }}
|
||||||
name: Release Please
|
name: Release Please
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.3.0"
|
".": "0.4.0"
|
||||||
}
|
}
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.4.0](https://github.com/Gitlawb/openclaude/compare/v0.3.0...v0.4.0) (2026-04-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add Alibaba Coding Plan (DashScope) provider support ([#509](https://github.com/Gitlawb/openclaude/issues/509)) ([43ac6db](https://github.com/Gitlawb/openclaude/commit/43ac6dba75537282da1e2ad8f855082bc4e25f1e))
|
||||||
|
* add NVIDIA NIM and MiniMax provider support ([#552](https://github.com/Gitlawb/openclaude/issues/552)) ([51191d6](https://github.com/Gitlawb/openclaude/commit/51191d61326e1f8319d70b3a3c0d9229e185a564))
|
||||||
|
* add ripgrep to Dockerfile for faster file searching ([#688](https://github.com/Gitlawb/openclaude/issues/688)) ([12dd375](https://github.com/Gitlawb/openclaude/commit/12dd3755c619cc27af3b151ae8fdb9d425a7b9a2))
|
||||||
|
* **api:** classify openai-compatible provider failures ([#708](https://github.com/Gitlawb/openclaude/issues/708)) ([80a00ac](https://github.com/Gitlawb/openclaude/commit/80a00acc2c6dc4657a78de7366f7a9ebc920bfbb))
|
||||||
|
* **vscode:** add full chat interface to OpenClaude extension ([#608](https://github.com/Gitlawb/openclaude/issues/608)) ([fbcd928](https://github.com/Gitlawb/openclaude/commit/fbcd928f7f8511da795aea3ad318bddf0ab9a1a7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* focus "Done" option after completing provider manager actions ([#718](https://github.com/Gitlawb/openclaude/issues/718)) ([d6f5130](https://github.com/Gitlawb/openclaude/commit/d6f5130c204d8ffe582212466768706cd7fd6774))
|
||||||
|
* **models:** prevent /models crash from non-string saved model values ([#691](https://github.com/Gitlawb/openclaude/issues/691)) ([6b2121d](https://github.com/Gitlawb/openclaude/commit/6b2121da12189fa7ce1f33394d18abd24cf8a01b))
|
||||||
|
* prevent crash in commands tab when description is undefined ([#730](https://github.com/Gitlawb/openclaude/issues/730)) ([eed77e6](https://github.com/Gitlawb/openclaude/commit/eed77e6579866a98384dcc948a0ad6406614ede3))
|
||||||
|
* strip comments before scanning for missing imports ([#676](https://github.com/Gitlawb/openclaude/issues/676)) ([a00b792](https://github.com/Gitlawb/openclaude/commit/a00b7928de9662ffb7ef6abd8cd040afe6f4f122))
|
||||||
|
* **ui:** show correct endpoint URL in intro screen for custom Anthropic endpoints ([#735](https://github.com/Gitlawb/openclaude/issues/735)) ([3424663](https://github.com/Gitlawb/openclaude/commit/34246635fb9a09499047a52e7f96ca9b36c8a85a))
|
||||||
|
|
||||||
## [0.3.0](https://github.com/Gitlawb/openclaude/compare/v0.2.3...v0.3.0) (2026-04-14)
|
## [0.3.0](https://github.com/Gitlawb/openclaude/compare/v0.2.3...v0.3.0) (2026-04-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,14 +36,11 @@ COPY --from=build /app/node_modules/ node_modules/
|
|||||||
COPY --from=build /app/package.json package.json
|
COPY --from=build /app/package.json package.json
|
||||||
COPY README.md ./
|
COPY README.md ./
|
||||||
|
|
||||||
# Install git — many CLI tool operations depend on it
|
# Install git and ripgrep — many CLI tool operations depend on them
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
RUN apt-get update && apt-get install -y --no-install-recommends git ripgrep \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Run as non-root user
|
# Run as non-root user
|
||||||
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
|
USER node
|
||||||
USER appuser
|
|
||||||
WORKDIR /home/appuser
|
|
||||||
ENV HOME=/home/appuser
|
|
||||||
|
|
||||||
ENTRYPOINT ["node", "/app/dist/cli.mjs"]
|
ENTRYPOINT ["node", "/app/dist/cli.mjs"]
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -15,6 +15,10 @@ OpenClaude is also mirrored to GitLawb:
|
|||||||
|
|
||||||
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
|
[Quick Start](#quick-start) | [Setup Guides](#setup-guides) | [Providers](#supported-providers) | [Source Build](#source-build-and-local-development) | [VS Code Extension](#vs-code-extension) | [Community](#community)
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/?repos=gitlawb%2Fopenclaude&type=date&legend=top-left)
|
||||||
|
|
||||||
## Why OpenClaude
|
## Why OpenClaude
|
||||||
|
|
||||||
- Use one CLI across cloud APIs and local model backends
|
- Use one CLI across cloud APIs and local model backends
|
||||||
@@ -88,6 +92,16 @@ $env:OPENAI_MODEL="qwen2.5-coder:7b"
|
|||||||
openclaude
|
openclaude
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Using Ollama's launch command
|
||||||
|
|
||||||
|
If you have [Ollama](https://ollama.com) installed, you can skip the env var setup entirely:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama launch openclaude --model qwen2.5-coder:7b
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically sets `ANTHROPIC_BASE_URL`, model routing, and auth so all API traffic goes through your local Ollama instance. Works with any model you have pulled — local or cloud.
|
||||||
|
|
||||||
## Setup Guides
|
## Setup Guides
|
||||||
|
|
||||||
Beginner-friendly guides:
|
Beginner-friendly guides:
|
||||||
@@ -110,7 +124,7 @@ Advanced and source-build guides:
|
|||||||
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
| GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
|
||||||
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
|
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
|
||||||
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
|
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
|
||||||
| Ollama | `/provider` or env vars | Local inference with no API key |
|
| Ollama | `/provider`, env vars, or `ollama launch` | Local inference with no API key |
|
||||||
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
| Atomic Chat | advanced setup | Local Apple Silicon backend |
|
||||||
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ OpenRouter model availability changes over time. If a model stops working, try a
|
|||||||
|
|
||||||
### Ollama
|
### Ollama
|
||||||
|
|
||||||
|
Using `ollama launch` (recommended if you have Ollama installed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama launch openclaude --model llama3.3:70b
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles all environment setup automatically — no env vars needed. Works with any local or cloud model available in your Ollama instance.
|
||||||
|
|
||||||
|
Using environment variables manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ollama pull llama3.3:70b
|
ollama pull llama3.3:70b
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@gitlawb/openclaude",
|
"name": "@gitlawb/openclaude",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
|
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -367,9 +367,17 @@ export const SeverityNumber = {};
|
|||||||
const full = pathMod.join(dir, ent.name)
|
const full = pathMod.join(dir, ent.name)
|
||||||
if (ent.isDirectory()) { walk(full); continue }
|
if (ent.isDirectory()) { walk(full); continue }
|
||||||
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
if (!/\.(ts|tsx)$/.test(ent.name)) continue
|
||||||
const code: string = fs.readFileSync(full, 'utf-8')
|
const rawCode: string = fs.readFileSync(full, 'utf-8')
|
||||||
const fileDir = pathMod.dirname(full)
|
const fileDir = pathMod.dirname(full)
|
||||||
|
|
||||||
|
// Strip comments before scanning for imports/requires.
|
||||||
|
// The regex scanner matches require()/import() patterns
|
||||||
|
// inside JSDoc comments, causing false-positive missing
|
||||||
|
// module detection that breaks the build with noop stubs.
|
||||||
|
const code = rawCode
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
|
||||||
|
.replace(/\/\/.*$/gm, '') // line comments
|
||||||
|
|
||||||
// Collect static imports: import { X } from '...'
|
// Collect static imports: import { X } from '...'
|
||||||
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
|
||||||
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
|
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopI
|
|||||||
import { render } from '../../ink.js';
|
import { render } from '../../ink.js';
|
||||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
|
||||||
import { clearMcpClientConfig, clearServerTokensFromLocalStorage, readClientSecret, saveMcpClientSecret } from '../../services/mcp/auth.js';
|
import {
|
||||||
|
clearMcpClientConfig,
|
||||||
|
clearServerTokensFromSecureStorage,
|
||||||
|
readClientSecret,
|
||||||
|
saveMcpClientSecret,
|
||||||
|
} from '../../services/mcp/auth.js'
|
||||||
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
import { doctorAllServers, doctorServer, type McpDoctorReport, type McpDoctorScopeFilter } from '../../services/mcp/doctor.js';
|
||||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||||
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig } from '../../services/mcp/config.js';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
readCodexCredentialsAsync,
|
readCodexCredentialsAsync,
|
||||||
} from '../utils/codexCredentials.js'
|
} from '../utils/codexCredentials.js'
|
||||||
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
|
||||||
|
import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js'
|
||||||
import {
|
import {
|
||||||
applySavedProfileToCurrentSession,
|
applySavedProfileToCurrentSession,
|
||||||
buildCodexOAuthProfileEnv,
|
buildCodexOAuthProfileEnv,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
import { Pane } from './design-system/Pane.js'
|
import { Pane } from './design-system/Pane.js'
|
||||||
import TextInput from './TextInput.js'
|
import TextInput from './TextInput.js'
|
||||||
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
|
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
|
||||||
|
import { useSetAppState } from '../state/AppState.js'
|
||||||
|
|
||||||
export type ProviderManagerResult = {
|
export type ProviderManagerResult = {
|
||||||
action: 'saved' | 'cancelled'
|
action: 'saved' | 'cancelled'
|
||||||
@@ -108,8 +110,8 @@ const FORM_STEPS: Array<{
|
|||||||
{
|
{
|
||||||
key: 'model',
|
key: 'model',
|
||||||
label: 'Default model',
|
label: 'Default model',
|
||||||
placeholder: 'e.g. llama3.1:8b',
|
placeholder: 'e.g. llama3.1:8b or glm-4.7, glm-4.7-flash',
|
||||||
helpText: 'Model name to use when this provider is active.',
|
helpText: 'Model name(s) to use. Separate multiple with commas; first is default.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'apiKey',
|
key: 'apiKey',
|
||||||
@@ -153,7 +155,12 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
|
|||||||
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
const keyInfo = profile.apiKey ? 'key set' : 'no key'
|
||||||
const providerKind =
|
const providerKind =
|
||||||
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
|
||||||
return `${providerKind} · ${profile.baseUrl} · ${profile.model} · ${keyInfo}${activeSuffix}`
|
const models = parseModelList(profile.model)
|
||||||
|
const modelDisplay =
|
||||||
|
models.length <= 3
|
||||||
|
? models.join(', ')
|
||||||
|
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
|
||||||
|
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGithubCredentialSourceFromEnv(
|
function getGithubCredentialSourceFromEnv(
|
||||||
@@ -320,6 +327,7 @@ function CodexOAuthSetup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
||||||
|
const setAppState = useSetAppState()
|
||||||
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
|
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
|
||||||
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
|
||||||
@@ -353,6 +361,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
const [cursorOffset, setCursorOffset] = React.useState(0)
|
const [cursorOffset, setCursorOffset] = React.useState(0)
|
||||||
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
|
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
|
||||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
|
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
|
||||||
|
const [menuFocusValue, setMenuFocusValue] = React.useState<string | undefined>()
|
||||||
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
|
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
|
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
|
||||||
@@ -568,23 +577,37 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
const githubError = activateGithubProvider()
|
const githubError = activateGithubProvider()
|
||||||
if (githubError) {
|
if (githubError) {
|
||||||
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
|
||||||
|
}))
|
||||||
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = setActiveProviderProfile(profileId)
|
const active = setActiveProviderProfile(profileId)
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setErrorMessage('Could not change active provider.')
|
setErrorMessage('Could not change active provider.')
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the session model to the new provider's first model.
|
||||||
|
// persistActiveProviderProfileModel (called by onChangeAppState) will
|
||||||
|
// not overwrite the multi-model list because it checks if the model
|
||||||
|
// is already in the profile's comma-separated model list.
|
||||||
|
const newModel = getPrimaryModel(active.model)
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mainLoopModel: newModel,
|
||||||
|
}))
|
||||||
|
|
||||||
providerLabel = active.name
|
providerLabel = active.name
|
||||||
const settingsOverrideError =
|
const settingsOverrideError =
|
||||||
clearStartupProviderOverrideFromUserSettings()
|
clearStartupProviderOverrideFromUserSettings()
|
||||||
@@ -613,16 +636,21 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
|
||||||
: `Active provider: ${active.name}`,
|
: `Active provider: ${active.name}`,
|
||||||
)
|
)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage(undefined)
|
setStatusMessage(undefined)
|
||||||
const detail = error instanceof Error ? error.message : String(error)
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
|
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function returnToMenu(): void {
|
||||||
|
setMenuFocusValue('done')
|
||||||
|
setScreen('menu')
|
||||||
|
}
|
||||||
|
|
||||||
function closeWithCancelled(message: string): void {
|
function closeWithCancelled(message: string): void {
|
||||||
onDone({ action: 'cancelled', message })
|
onDone({ action: 'cancelled', message })
|
||||||
}
|
}
|
||||||
@@ -800,7 +828,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
setEditingProfileId(null)
|
setEditingProfileId(null)
|
||||||
setFormStepIndex(0)
|
setFormStepIndex(0)
|
||||||
setErrorMessage(undefined)
|
setErrorMessage(undefined)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOllamaSelection(): React.ReactNode {
|
function renderOllamaSelection(): React.ReactNode {
|
||||||
@@ -923,7 +951,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeybinding('confirm:no', handleBackFromForm, {
|
useKeybinding('confirm:no', handleBackFromForm, {
|
||||||
@@ -1004,11 +1032,31 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
label: 'LM Studio',
|
label: 'LM Studio',
|
||||||
description: 'Local LM Studio endpoint',
|
description: 'Local LM Studio endpoint',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'dashscope-cn',
|
||||||
|
label: 'Alibaba Coding Plan (China)',
|
||||||
|
description: 'Alibaba DashScope China endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dashscope-intl',
|
||||||
|
label: 'Alibaba Coding Plan',
|
||||||
|
description: 'Alibaba DashScope International endpoint',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'custom',
|
value: 'custom',
|
||||||
label: 'Custom',
|
label: 'Custom',
|
||||||
description: 'Any OpenAI-compatible provider',
|
description: 'Any OpenAI-compatible provider',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'nvidia-nim',
|
||||||
|
label: 'NVIDIA NIM',
|
||||||
|
description: 'NVIDIA NIM endpoint',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'minimax',
|
||||||
|
label: 'MiniMax',
|
||||||
|
description: 'MiniMax API endpoint',
|
||||||
|
},
|
||||||
...(mode === 'first-run'
|
...(mode === 'first-run'
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -1046,7 +1094,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
closeWithCancelled('Provider setup skipped')
|
closeWithCancelled('Provider setup skipped')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
}}
|
}}
|
||||||
visibleOptionCount={Math.min(13, options.length)}
|
visibleOptionCount={Math.min(13, options.length)}
|
||||||
/>
|
/>
|
||||||
@@ -1246,6 +1294,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCancel={() => closeWithCancelled('Provider manager closed')}
|
onCancel={() => closeWithCancelled('Provider manager closed')}
|
||||||
|
defaultFocusValue={menuFocusValue}
|
||||||
visibleOptionCount={options.length}
|
visibleOptionCount={options.length}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1293,8 +1342,8 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
description: 'Return to provider manager',
|
description: 'Return to provider manager',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onChange={() => setScreen('menu')}
|
onChange={() => returnToMenu()}
|
||||||
onCancel={() => setScreen('menu')}
|
onCancel={() => returnToMenu()}
|
||||||
visibleOptionCount={1}
|
visibleOptionCount={1}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1309,7 +1358,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
<Select
|
<Select
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
onCancel={() => setScreen('menu')}
|
onCancel={() => returnToMenu()}
|
||||||
visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))}
|
visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1350,7 +1399,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
'Codex OAuth login finished, but the provider profile could not be saved.',
|
'Codex OAuth login finished, but the provider profile could not be saved.',
|
||||||
)
|
)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,7 +1411,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
|
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
|
||||||
)
|
)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,7 +1445,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
|
|
||||||
setStatusMessage(message)
|
setStatusMessage(message)
|
||||||
setErrorMessage(undefined)
|
setErrorMessage(undefined)
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1436,7 +1485,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
refreshProfiles()
|
refreshProfiles()
|
||||||
setStatusMessage('GitHub provider deleted')
|
setStatusMessage('GitHub provider deleted')
|
||||||
}
|
}
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1471,7 +1520,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
|
|||||||
: 'Provider deleted',
|
: 'Provider deleted',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
setScreen('menu')
|
returnToMenu()
|
||||||
},
|
},
|
||||||
{ includeGithub: true },
|
{ includeGithub: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,17 +117,28 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
|
|||||||
const baseUrl = resolvedRequest.baseUrl
|
const baseUrl = resolvedRequest.baseUrl
|
||||||
const isLocal = isLocalProviderUrl(baseUrl)
|
const isLocal = isLocalProviderUrl(baseUrl)
|
||||||
let name = 'OpenAI'
|
let name = 'OpenAI'
|
||||||
// Override to Codex when resolved endpoint is Codex
|
if (/nvidia/i.test(baseUrl) || /nvidia/i.test(rawModel) || process.env.NVIDIA_NIM)
|
||||||
if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex')) {
|
name = 'NVIDIA NIM'
|
||||||
|
else if (/minimax/i.test(baseUrl) || /minimax/i.test(rawModel) || process.env.MINIMAX_API_KEY)
|
||||||
|
name = 'MiniMax'
|
||||||
|
else if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex'))
|
||||||
name = 'Codex'
|
name = 'Codex'
|
||||||
} else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek'
|
else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel))
|
||||||
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter'
|
name = 'DeepSeek'
|
||||||
else if (/together/i.test(baseUrl)) name = 'Together AI'
|
else if (/openrouter/i.test(baseUrl))
|
||||||
else if (/groq/i.test(baseUrl)) name = 'Groq'
|
name = 'OpenRouter'
|
||||||
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel)) name = 'Mistral'
|
else if (/together/i.test(baseUrl))
|
||||||
else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI'
|
name = 'Together AI'
|
||||||
else if (/llama/i.test(rawModel)) name = 'Meta Llama'
|
else if (/groq/i.test(baseUrl))
|
||||||
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl)
|
name = 'Groq'
|
||||||
|
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel))
|
||||||
|
name = 'Mistral'
|
||||||
|
else if (/azure/i.test(baseUrl))
|
||||||
|
name = 'Azure OpenAI'
|
||||||
|
else if (/llama/i.test(rawModel))
|
||||||
|
name = 'Meta Llama'
|
||||||
|
else if (isLocal)
|
||||||
|
name = getLocalOpenAICompatibleProviderLabel(baseUrl)
|
||||||
|
|
||||||
// Resolve model alias to actual model name + reasoning effort
|
// Resolve model alias to actual model name + reasoning effort
|
||||||
let displayModel = resolvedRequest.resolvedModel
|
let displayModel = resolvedRequest.resolvedModel
|
||||||
@@ -142,7 +153,9 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
|
|||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
const modelSetting = settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
|
const modelSetting = settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
|
||||||
const resolvedModel = parseUserSpecifiedModel(modelSetting)
|
const resolvedModel = parseUserSpecifiedModel(modelSetting)
|
||||||
return { name: 'Anthropic', model: resolvedModel, baseUrl: 'https://api.anthropic.com', isLocal: false }
|
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
|
||||||
|
const isLocal = isLocalProviderUrl(baseUrl)
|
||||||
|
return { name: 'Anthropic', model: resolvedModel, baseUrl, isLocal }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Box drawing ──────────────────────────────────────────────────────────────
|
// ─── Box drawing ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { afterEach, expect, test } from 'bun:test'
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
// MACRO is replaced at build time by Bun.define but not in test mode.
|
||||||
|
// Define it globally so tests that import modules using MACRO don't crash.
|
||||||
|
;(globalThis as Record<string, unknown>).MACRO = {
|
||||||
|
VERSION: '99.0.0',
|
||||||
|
DISPLAY_VERSION: '0.0.0-test',
|
||||||
|
BUILD_TIME: new Date().toISOString(),
|
||||||
|
ISSUES_EXPLAINER: 'report the issue at https://github.com/anthropics/claude-code/issues',
|
||||||
|
PACKAGE_URL: '@gitlawb/openclaude',
|
||||||
|
NATIVE_PACKAGE_URL: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js'
|
import { getSystemPrompt, DEFAULT_AGENT_PROMPT } from './prompts.js'
|
||||||
import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js'
|
import { CLI_SYSPROMPT_PREFIXES, getCLISyspromptPrefix } from './system.js'
|
||||||
import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js'
|
import { CLAUDE_CODE_GUIDE_AGENT } from '../tools/AgentTool/built-in/claudeCodeGuideAgent.js'
|
||||||
|
|||||||
75
src/entrypoints/mcp.test.ts
Normal file
75
src/entrypoints/mcp.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, mock } from 'bun:test'
|
||||||
|
import { getCombinedTools, loadReexposedMcpTools } from './mcp.js'
|
||||||
|
import type { Tool as InternalTool } from '../Tool.js'
|
||||||
|
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||||
|
import type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
|
||||||
|
// Mock the MCP client service to control the tools and connections returned
|
||||||
|
const mockGetMcpToolsCommandsAndResources = mock(async (onConnectionAttempt: any) => {})
|
||||||
|
mock.module('../services/mcp/client.js', () => ({
|
||||||
|
getMcpToolsCommandsAndResources: mockGetMcpToolsCommandsAndResources
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('getCombinedTools', () => {
|
||||||
|
it('deduplicates builtins when mcpTools have the same name, prioritizing mcpTools', () => {
|
||||||
|
const builtinBash = { name: 'Bash', isMcp: false } as unknown as InternalTool
|
||||||
|
const builtinRead = { name: 'Read', isMcp: false } as unknown as InternalTool
|
||||||
|
const mcpBash = { name: 'Bash', isMcp: true } as unknown as InternalTool
|
||||||
|
|
||||||
|
const builtins = [builtinBash, builtinRead]
|
||||||
|
const mcpTools = [mcpBash]
|
||||||
|
|
||||||
|
const result = getCombinedTools(builtins, mcpTools)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toBe(mcpBash)
|
||||||
|
expect(result[1]).toBe(builtinRead)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadReexposedMcpTools', () => {
|
||||||
|
it('loads tools and clients regardless of connection state (including needs-auth)', async () => {
|
||||||
|
// Setup the mock to simulate yielding a needs-auth server and a connected server
|
||||||
|
mockGetMcpToolsCommandsAndResources.mockImplementation(async (onConnectionAttempt) => {
|
||||||
|
const needsAuthClient = {
|
||||||
|
name: 'auth-server',
|
||||||
|
type: 'needs-auth',
|
||||||
|
config: {}
|
||||||
|
} as MCPServerConnection
|
||||||
|
|
||||||
|
const authTool = {
|
||||||
|
name: 'mcp__auth-server__authenticate',
|
||||||
|
isMcp: true
|
||||||
|
} as unknown as InternalTool
|
||||||
|
|
||||||
|
const connectedClient = {
|
||||||
|
name: 'connected-server',
|
||||||
|
type: 'connected',
|
||||||
|
config: {},
|
||||||
|
client: {}
|
||||||
|
} as MCPServerConnection
|
||||||
|
|
||||||
|
const connectedTool = {
|
||||||
|
name: 'mcp__connected-server__do_thing',
|
||||||
|
isMcp: true
|
||||||
|
} as unknown as InternalTool
|
||||||
|
|
||||||
|
// Simulate the callback behavior
|
||||||
|
onConnectionAttempt({ client: needsAuthClient, tools: [authTool], commands: [] })
|
||||||
|
onConnectionAttempt({ client: connectedClient, tools: [connectedTool], commands: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { mcpClients, mcpTools } = await loadReexposedMcpTools()
|
||||||
|
|
||||||
|
expect(mcpClients).toHaveLength(2)
|
||||||
|
expect(mcpClients[0].type).toBe('needs-auth')
|
||||||
|
expect(mcpClients[1].type).toBe('connected')
|
||||||
|
|
||||||
|
expect(mcpTools).toHaveLength(2)
|
||||||
|
expect(mcpTools[0].name).toBe('mcp__auth-server__authenticate')
|
||||||
|
expect(mcpTools[1].name).toBe('mcp__connected-server__do_thing')
|
||||||
|
|
||||||
|
// Reset mock for other tests
|
||||||
|
mockGetMcpToolsCommandsAndResources.mockReset()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,7 @@ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ??= 'true'
|
|||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||||
|
import { ZodError } from 'zod'
|
||||||
import {
|
import {
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
type CallToolResult,
|
type CallToolResult,
|
||||||
@@ -17,9 +18,12 @@ import {
|
|||||||
import { getDefaultAppState } from 'src/state/AppStateStore.js'
|
import { getDefaultAppState } from 'src/state/AppStateStore.js'
|
||||||
import review from '../commands/review.js'
|
import review from '../commands/review.js'
|
||||||
import type { Command } from '../commands.js'
|
import type { Command } from '../commands.js'
|
||||||
|
import { getMcpToolsCommandsAndResources } from '../services/mcp/client.js'
|
||||||
|
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||||
import {
|
import {
|
||||||
findToolByName,
|
findToolByName,
|
||||||
getEmptyToolPermissionContext,
|
getEmptyToolPermissionContext,
|
||||||
|
type Tool as InternalTool,
|
||||||
type ToolUseContext,
|
type ToolUseContext,
|
||||||
} from '../Tool.js'
|
} from '../Tool.js'
|
||||||
import { getTools } from '../tools.js'
|
import { getTools } from '../tools.js'
|
||||||
@@ -39,6 +43,32 @@ type ToolOutput = Tool['outputSchema']
|
|||||||
|
|
||||||
const MCP_COMMANDS: Command[] = [review]
|
const MCP_COMMANDS: Command[] = [review]
|
||||||
|
|
||||||
|
export function getCombinedTools(
|
||||||
|
builtins: InternalTool[],
|
||||||
|
mcpTools: InternalTool[],
|
||||||
|
): InternalTool[] {
|
||||||
|
const mcpToolNames = new Set(mcpTools.map(t => t.name))
|
||||||
|
const deduplicatedBuiltins = builtins.filter(t => !mcpToolNames.has(t.name))
|
||||||
|
|
||||||
|
return [...mcpTools, ...deduplicatedBuiltins]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadReexposedMcpTools(): Promise<{
|
||||||
|
mcpClients: MCPServerConnection[]
|
||||||
|
mcpTools: InternalTool[]
|
||||||
|
}> {
|
||||||
|
const mcpClients: MCPServerConnection[] = []
|
||||||
|
const mcpTools: InternalTool[] = []
|
||||||
|
|
||||||
|
// Load configured MCP clients and their tools
|
||||||
|
await getMcpToolsCommandsAndResources(({ client, tools: clientTools }) => {
|
||||||
|
mcpClients.push(client)
|
||||||
|
mcpTools.push(...clientTools)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { mcpClients, mcpTools }
|
||||||
|
}
|
||||||
|
|
||||||
export async function startMCPServer(
|
export async function startMCPServer(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
debug: boolean,
|
debug: boolean,
|
||||||
@@ -63,12 +93,13 @@ export async function startMCPServer(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { mcpClients, mcpTools } = await loadReexposedMcpTools()
|
||||||
|
|
||||||
server.setRequestHandler(
|
server.setRequestHandler(
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
async (): Promise<ListToolsResult> => {
|
async (): Promise<ListToolsResult> => {
|
||||||
// TODO: Also re-expose any MCP tools
|
|
||||||
const toolPermissionContext = getEmptyToolPermissionContext()
|
const toolPermissionContext = getEmptyToolPermissionContext()
|
||||||
const tools = getTools(toolPermissionContext)
|
const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
|
||||||
return {
|
return {
|
||||||
tools: await Promise.all(
|
tools: await Promise.all(
|
||||||
tools.map(async tool => {
|
tools.map(async tool => {
|
||||||
@@ -94,7 +125,7 @@ export async function startMCPServer(
|
|||||||
tools,
|
tools,
|
||||||
agents: [],
|
agents: [],
|
||||||
}),
|
}),
|
||||||
inputSchema: zodToJsonSchema(tool.inputSchema) as ToolInput,
|
inputSchema: (tool.inputJSONSchema ?? zodToJsonSchema(tool.inputSchema)) as ToolInput,
|
||||||
outputSchema,
|
outputSchema,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -107,8 +138,7 @@ export async function startMCPServer(
|
|||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
async ({ params: { name, arguments: args } }): Promise<CallToolResult> => {
|
async ({ params: { name, arguments: args } }): Promise<CallToolResult> => {
|
||||||
const toolPermissionContext = getEmptyToolPermissionContext()
|
const toolPermissionContext = getEmptyToolPermissionContext()
|
||||||
// TODO: Also re-expose any MCP tools
|
const tools = getCombinedTools(getTools(toolPermissionContext), mcpTools)
|
||||||
const tools = getTools(toolPermissionContext)
|
|
||||||
const tool = findToolByName(tools, name)
|
const tool = findToolByName(tools, name)
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error(`Tool ${name} not found`)
|
throw new Error(`Tool ${name} not found`)
|
||||||
@@ -123,7 +153,7 @@ export async function startMCPServer(
|
|||||||
tools,
|
tools,
|
||||||
mainLoopModel: getMainLoopModel(),
|
mainLoopModel: getMainLoopModel(),
|
||||||
thinkingConfig: { type: 'disabled' },
|
thinkingConfig: { type: 'disabled' },
|
||||||
mcpClients: [],
|
mcpClients,
|
||||||
mcpResources: {},
|
mcpResources: {},
|
||||||
isNonInteractiveSession: true,
|
isNonInteractiveSession: true,
|
||||||
debug,
|
debug,
|
||||||
@@ -140,13 +170,16 @@ export async function startMCPServer(
|
|||||||
updateAttributionState: () => {},
|
updateAttributionState: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: validate input types with zod
|
|
||||||
try {
|
try {
|
||||||
if (!tool.isEnabled()) {
|
if (!tool.isEnabled()) {
|
||||||
throw new Error(`Tool ${name} is not enabled`)
|
throw new Error(`Tool ${name} is not enabled`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate input types with zod
|
||||||
|
const parsedArgs = tool.inputSchema.parse(args ?? {})
|
||||||
|
|
||||||
const validationResult = await tool.validateInput?.(
|
const validationResult = await tool.validateInput?.(
|
||||||
(args as never) ?? {},
|
(parsedArgs as never) ?? {},
|
||||||
toolUseContext,
|
toolUseContext,
|
||||||
)
|
)
|
||||||
if (validationResult && !validationResult.result) {
|
if (validationResult && !validationResult.result) {
|
||||||
@@ -155,7 +188,7 @@ export async function startMCPServer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const finalResult = await tool.call(
|
const finalResult = await tool.call(
|
||||||
(args ?? {}) as never,
|
(parsedArgs ?? {}) as never,
|
||||||
toolUseContext,
|
toolUseContext,
|
||||||
hasPermissionsToUseTool,
|
hasPermissionsToUseTool,
|
||||||
createAssistantMessage({
|
createAssistantMessage({
|
||||||
@@ -163,20 +196,50 @@ export async function startMCPServer(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let content: CallToolResult['content']
|
||||||
|
const data = finalResult.data as string | { type: string; text?: string; source?: { type: string; media_type: string; data: string } }[] | unknown
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
content = [{ type: 'text', text: data }]
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
content = data.map((block: any) => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return { type: 'text', text: block.text || '' }
|
||||||
|
} else if (block.type === 'image' && block.source) {
|
||||||
|
return {
|
||||||
|
type: 'image',
|
||||||
|
data: block.source.data,
|
||||||
|
mimeType: block.source.media_type,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects, no-console
|
||||||
|
console.warn(`Unmapped content block type from tool ${name}: ${block.type || 'unknown'}`)
|
||||||
|
return { type: 'text', text: jsonStringify(block) }
|
||||||
|
}
|
||||||
|
}) as CallToolResult['content']
|
||||||
|
} else {
|
||||||
|
content = [{ type: 'text', text: jsonStringify(data) }]
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content,
|
||||||
{
|
isError: !!(finalResult as any).isError,
|
||||||
type: 'text' as const,
|
|
||||||
text:
|
|
||||||
typeof finalResult === 'string'
|
|
||||||
? finalResult
|
|
||||||
: jsonStringify(finalResult.data),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error)
|
logError(error)
|
||||||
|
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Tool ${name} input is invalid:\n${error.errors.map(e => `- ${e.path.join('.')}: ${e.message}`).join('\n')}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parts =
|
const parts =
|
||||||
error instanceof Error ? getErrorParts(error) : [String(error)]
|
error instanceof Error ? getErrorParts(error) : [String(error)]
|
||||||
const errorText = parts.filter(Boolean).join('\n').trim() || 'Error'
|
const errorText = parts.filter(Boolean).join('\n').trim() || 'Error'
|
||||||
@@ -201,3 +264,4 @@ export async function startMCPServer(
|
|||||||
|
|
||||||
return await runServer()
|
return await runServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APIError } from '@anthropic-ai/sdk'
|
import { APIError } from '@anthropic-ai/sdk'
|
||||||
|
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
|
||||||
import type {
|
import type {
|
||||||
ResolvedCodexCredentials,
|
ResolvedCodexCredentials,
|
||||||
ResolvedProviderRequest,
|
ResolvedProviderRequest,
|
||||||
@@ -559,12 +560,15 @@ export async function performCodexRequest(options: {
|
|||||||
}
|
}
|
||||||
headers.originator ??= 'openclaude'
|
headers.originator ??= 'openclaude'
|
||||||
|
|
||||||
const response = await fetch(`${options.request.baseUrl}/responses`, {
|
const response = await fetchWithProxyRetry(
|
||||||
method: 'POST',
|
`${options.request.baseUrl}/responses`,
|
||||||
headers,
|
{
|
||||||
body: JSON.stringify(body),
|
method: 'POST',
|
||||||
signal: options.signal,
|
headers,
|
||||||
})
|
body: JSON.stringify(body),
|
||||||
|
signal: options.signal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorBody = await response.text().catch(() => 'unknown error')
|
const errorBody = await response.text().catch(() => 'unknown error')
|
||||||
|
|||||||
44
src/services/api/errors.openaiCompatibility.test.ts
Normal file
44
src/services/api/errors.openaiCompatibility.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { APIError } from '@anthropic-ai/sdk'
|
||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { getAssistantMessageFromError } from './errors.js'
|
||||||
|
|
||||||
|
function getFirstText(message: ReturnType<typeof getAssistantMessageFromError>): string {
|
||||||
|
const first = message.message.content[0]
|
||||||
|
if (!first || typeof first !== 'object' || !('text' in first)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return typeof first.text === 'string' ? first.text : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
test('maps endpoint_not_found category markers to actionable setup guidance', () => {
|
||||||
|
const error = APIError.generate(
|
||||||
|
404,
|
||||||
|
undefined,
|
||||||
|
'OpenAI API error 404: Not Found [openai_category=endpoint_not_found] Hint: Confirm OPENAI_BASE_URL includes /v1.',
|
||||||
|
new Headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
|
||||||
|
const text = getFirstText(message)
|
||||||
|
|
||||||
|
expect(message.isApiErrorMessage).toBe(true)
|
||||||
|
expect(text).toContain('Provider endpoint was not found')
|
||||||
|
expect(text).toContain('OPENAI_BASE_URL')
|
||||||
|
expect(text).toContain('/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps tool_call_incompatible category markers to model/tool guidance', () => {
|
||||||
|
const error = APIError.generate(
|
||||||
|
400,
|
||||||
|
undefined,
|
||||||
|
'OpenAI API error 400: tool_calls are not supported [openai_category=tool_call_incompatible]',
|
||||||
|
new Headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = getAssistantMessageFromError(error, 'qwen2.5-coder:7b')
|
||||||
|
const text = getFirstText(message)
|
||||||
|
|
||||||
|
expect(text).toContain('rejected tool-calling payloads')
|
||||||
|
expect(text).toContain('/model')
|
||||||
|
})
|
||||||
@@ -50,9 +50,110 @@ import {
|
|||||||
} from '../claudeAiLimits.js'
|
} from '../claudeAiLimits.js'
|
||||||
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
|
import { shouldProcessRateLimits } from '../rateLimitMocking.js' // Used for /mock-limits command
|
||||||
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
|
import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js'
|
||||||
|
import {
|
||||||
|
extractOpenAICategoryMarker,
|
||||||
|
type OpenAICompatibilityFailureCategory,
|
||||||
|
} from './openaiErrorClassification.js'
|
||||||
|
|
||||||
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
||||||
|
|
||||||
|
function stripOpenAICompatibilityMetadata(message: string): string {
|
||||||
|
return message
|
||||||
|
.replace(/\s*\[openai_category=[a-z_]+\]\s*/g, ' ')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOpenAICompatibilityFailureToAssistantMessage(options: {
|
||||||
|
category: OpenAICompatibilityFailureCategory
|
||||||
|
model: string
|
||||||
|
rawMessage: string
|
||||||
|
}): AssistantMessage {
|
||||||
|
const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model'
|
||||||
|
const compactHint = getIsNonInteractiveSession()
|
||||||
|
? 'Reduce prompt size or start a new session.'
|
||||||
|
: 'Run /compact or start a new session with /new.'
|
||||||
|
|
||||||
|
switch (options.category) {
|
||||||
|
case 'localhost_resolution_failed':
|
||||||
|
case 'connection_refused':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content:
|
||||||
|
'Could not connect to the local OpenAI-compatible provider. Ensure the local server is running, then use OPENAI_BASE_URL=http://127.0.0.1:11434/v1 for Ollama.',
|
||||||
|
error: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'endpoint_not_found':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content:
|
||||||
|
'Provider endpoint was not found. Confirm OPENAI_BASE_URL targets an OpenAI-compatible /v1 endpoint (for Ollama: http://127.0.0.1:11434/v1).',
|
||||||
|
error: 'invalid_request',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'model_not_found':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `The selected model (${options.model}) is not available on this provider. Run ${switchCmd} to choose another model, or verify installed local models (for Ollama: ollama list).`,
|
||||||
|
error: 'invalid_request',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'auth_invalid':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: Authentication failed for your OpenAI-compatible provider. Verify OPENAI_API_KEY and endpoint-specific auth requirements.`,
|
||||||
|
error: 'authentication_failed',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'rate_limited':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: Provider rate limit reached. Retry in a few seconds.`,
|
||||||
|
error: 'rate_limit',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'request_timeout':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: Provider request timed out. Local models may be loading or overloaded; retry shortly or increase API_TIMEOUT_MS.`,
|
||||||
|
error: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'context_overflow':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `The conversation exceeded the provider context limit. ${compactHint}`,
|
||||||
|
error: 'invalid_request',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'tool_call_incompatible':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `The selected provider/model rejected tool-calling payloads. Try ${switchCmd} to pick a tool-capable model or continue without tools.`,
|
||||||
|
error: 'invalid_request',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'malformed_provider_response':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: Provider returned a malformed response. Confirm endpoint compatibility and check local proxy/network middleware.`,
|
||||||
|
error: 'unknown',
|
||||||
|
errorDetails: stripOpenAICompatibilityMetadata(options.rawMessage),
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'provider_unavailable':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: Provider is temporarily unavailable. Retry in a moment.`,
|
||||||
|
error: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'network_error':
|
||||||
|
case 'unknown':
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
|
||||||
|
error: 'unknown',
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
return createAssistantAPIErrorMessage({
|
||||||
|
content: `${API_ERROR_MESSAGE_PREFIX}: ${stripOpenAICompatibilityMetadata(options.rawMessage)}`,
|
||||||
|
error: 'unknown',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startsWithApiErrorPrefix(text: string): boolean {
|
export function startsWithApiErrorPrefix(text: string): boolean {
|
||||||
return (
|
return (
|
||||||
text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
|
text.startsWith(API_ERROR_MESSAGE_PREFIX) ||
|
||||||
@@ -457,6 +558,19 @@ export function getAssistantMessageFromError(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI-compatible transport and HTTP failures include structured category
|
||||||
|
// markers from openaiShim.ts for actionable end-user remediation.
|
||||||
|
if (error instanceof APIError) {
|
||||||
|
const openaiCategory = extractOpenAICategoryMarker(error.message)
|
||||||
|
if (openaiCategory) {
|
||||||
|
return mapOpenAICompatibilityFailureToAssistantMessage({
|
||||||
|
category: openaiCategory,
|
||||||
|
model,
|
||||||
|
rawMessage: error.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for emergency capacity off switch for Opus PAYG users
|
// Check for emergency capacity off switch for Opus PAYG users
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
|
|||||||
86
src/services/api/fetchWithProxyRetry.test.ts
Normal file
86
src/services/api/fetchWithProxyRetry.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { _resetKeepAliveForTesting } from '../../utils/proxy.js'
|
||||||
|
import {
|
||||||
|
fetchWithProxyRetry,
|
||||||
|
isRetryableFetchError,
|
||||||
|
} from './fetchWithProxyRetry.js'
|
||||||
|
|
||||||
|
type FetchType = typeof globalThis.fetch
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const originalEnv = {
|
||||||
|
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||||
|
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.HTTP_PROXY = 'http://127.0.0.1:15236'
|
||||||
|
delete process.env.HTTPS_PROXY
|
||||||
|
_resetKeepAliveForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
|
||||||
|
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
|
||||||
|
_resetKeepAliveForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isRetryableFetchError matches Bun socket-closed failures', () => {
|
||||||
|
expect(
|
||||||
|
isRetryableFetchError(
|
||||||
|
new Error(
|
||||||
|
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchWithProxyRetry retries once with keepalive disabled after socket closure', async () => {
|
||||||
|
const calls: Array<RequestInit | undefined> = []
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
calls.push(init)
|
||||||
|
if (calls.length === 1) {
|
||||||
|
throw new Error(
|
||||||
|
'The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return new Response('ok')
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const response = await fetchWithProxyRetry('https://example.com/search', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await response.text()).toBe('ok')
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect((calls[0] as RequestInit & { proxy?: string }).proxy).toBe(
|
||||||
|
'http://127.0.0.1:15236',
|
||||||
|
)
|
||||||
|
expect((calls[0] as RequestInit).keepalive).toBeUndefined()
|
||||||
|
expect((calls[1] as RequestInit).keepalive).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fetchWithProxyRetry does not retry non-network errors', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
attempts += 1
|
||||||
|
throw new Error('400 bad request')
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
await expect(fetchWithProxyRetry('https://example.com')).rejects.toThrow(
|
||||||
|
'400 bad request',
|
||||||
|
)
|
||||||
|
expect(attempts).toBe(1)
|
||||||
|
})
|
||||||
44
src/services/api/fetchWithProxyRetry.ts
Normal file
44
src/services/api/fetchWithProxyRetry.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { disableKeepAlive, getProxyFetchOptions } from '../../utils/proxy.js'
|
||||||
|
|
||||||
|
const RETRYABLE_FETCH_ERROR_PATTERN =
|
||||||
|
/socket connection was closed unexpectedly|ECONNRESET|EPIPE|socket hang up|Connection reset by peer|fetch failed/i
|
||||||
|
|
||||||
|
export function isRetryableFetchError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return RETRYABLE_FETCH_ERROR_PATTERN.test(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithProxyRetry(
|
||||||
|
input: string | URL | Request,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: { forAnthropicAPI?: boolean; maxAttempts?: number },
|
||||||
|
): Promise<Response> {
|
||||||
|
const maxAttempts = Math.max(1, options?.maxAttempts ?? 2)
|
||||||
|
let lastError: unknown
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetch(input, {
|
||||||
|
...init,
|
||||||
|
...getProxyFetchOptions({
|
||||||
|
forAnthropicAPI: options?.forAnthropicAPI,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
if (attempt >= maxAttempts || !isRetryableFetchError(error)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
disableKeepAlive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error('Fetch failed without an error object')
|
||||||
|
}
|
||||||
97
src/services/api/openaiErrorClassification.test.ts
Normal file
97
src/services/api/openaiErrorClassification.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildOpenAICompatibilityErrorMessage,
|
||||||
|
classifyOpenAIHttpFailure,
|
||||||
|
classifyOpenAINetworkFailure,
|
||||||
|
extractOpenAICategoryMarker,
|
||||||
|
formatOpenAICategoryMarker,
|
||||||
|
} from './openaiErrorClassification.js'
|
||||||
|
|
||||||
|
test('classifies localhost ECONNREFUSED as connection_refused', () => {
|
||||||
|
const error = Object.assign(new TypeError('fetch failed'), {
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failure = classifyOpenAINetworkFailure(error, {
|
||||||
|
url: 'http://localhost:11434/v1/chat/completions',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('connection_refused')
|
||||||
|
expect(failure.retryable).toBe(true)
|
||||||
|
expect(failure.code).toBe('ECONNREFUSED')
|
||||||
|
expect(failure.hint).toContain('local server is running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies localhost ENOTFOUND as localhost_resolution_failed', () => {
|
||||||
|
const error = Object.assign(new TypeError('getaddrinfo ENOTFOUND localhost'), {
|
||||||
|
code: 'ENOTFOUND',
|
||||||
|
})
|
||||||
|
|
||||||
|
const failure = classifyOpenAINetworkFailure(error, {
|
||||||
|
url: 'http://localhost:11434/v1/chat/completions',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('localhost_resolution_failed')
|
||||||
|
expect(failure.retryable).toBe(true)
|
||||||
|
expect(failure.code).toBe('ENOTFOUND')
|
||||||
|
expect(failure.hint).toContain('127.0.0.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies model-not-found 404 responses', () => {
|
||||||
|
const failure = classifyOpenAIHttpFailure({
|
||||||
|
status: 404,
|
||||||
|
body: 'The model qwen2.5-coder:7b was not found',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('model_not_found')
|
||||||
|
expect(failure.retryable).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies generic 404 responses as endpoint_not_found', () => {
|
||||||
|
const failure = classifyOpenAIHttpFailure({
|
||||||
|
status: 404,
|
||||||
|
body: 'Not Found',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('endpoint_not_found')
|
||||||
|
expect(failure.hint).toContain('/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies context-overflow responses', () => {
|
||||||
|
const failure = classifyOpenAIHttpFailure({
|
||||||
|
status: 500,
|
||||||
|
body: 'request too large: maximum context length exceeded',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('context_overflow')
|
||||||
|
expect(failure.retryable).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies tool compatibility failures', () => {
|
||||||
|
const failure = classifyOpenAIHttpFailure({
|
||||||
|
status: 400,
|
||||||
|
body: 'tool_calls are not supported by this model',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failure.category).toBe('tool_call_incompatible')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('embeds and extracts category markers in formatted messages', () => {
|
||||||
|
const marker = formatOpenAICategoryMarker('endpoint_not_found')
|
||||||
|
expect(marker).toBe('[openai_category=endpoint_not_found]')
|
||||||
|
|
||||||
|
const formatted = buildOpenAICompatibilityErrorMessage('OpenAI API error 404: Not Found', {
|
||||||
|
category: 'endpoint_not_found',
|
||||||
|
hint: 'Confirm OPENAI_BASE_URL includes /v1.',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(formatted).toContain('[openai_category=endpoint_not_found]')
|
||||||
|
expect(formatted).toContain('Hint: Confirm OPENAI_BASE_URL includes /v1.')
|
||||||
|
expect(extractOpenAICategoryMarker(formatted)).toBe('endpoint_not_found')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores unknown category markers during extraction', () => {
|
||||||
|
const malformed = 'OpenAI API error 500 [openai_category=totally_fake_category]'
|
||||||
|
expect(extractOpenAICategoryMarker(malformed)).toBeUndefined()
|
||||||
|
})
|
||||||
355
src/services/api/openaiErrorClassification.ts
Normal file
355
src/services/api/openaiErrorClassification.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
export type OpenAICompatibilityFailureCategory =
|
||||||
|
| 'connection_refused'
|
||||||
|
| 'localhost_resolution_failed'
|
||||||
|
| 'request_timeout'
|
||||||
|
| 'network_error'
|
||||||
|
| 'auth_invalid'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'model_not_found'
|
||||||
|
| 'endpoint_not_found'
|
||||||
|
| 'context_overflow'
|
||||||
|
| 'tool_call_incompatible'
|
||||||
|
| 'malformed_provider_response'
|
||||||
|
| 'provider_unavailable'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
export type OpenAICompatibilityFailure = {
|
||||||
|
source: 'network' | 'http'
|
||||||
|
category: OpenAICompatibilityFailureCategory
|
||||||
|
retryable: boolean
|
||||||
|
message: string
|
||||||
|
hint?: string
|
||||||
|
code?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENAI_CATEGORY_MARKER_PREFIX = '[openai_category='
|
||||||
|
|
||||||
|
const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])
|
||||||
|
|
||||||
|
const OPENAI_COMPATIBILITY_FAILURE_CATEGORIES: ReadonlySet<OpenAICompatibilityFailureCategory> =
|
||||||
|
new Set<OpenAICompatibilityFailureCategory>([
|
||||||
|
'connection_refused',
|
||||||
|
'localhost_resolution_failed',
|
||||||
|
'request_timeout',
|
||||||
|
'network_error',
|
||||||
|
'auth_invalid',
|
||||||
|
'rate_limited',
|
||||||
|
'model_not_found',
|
||||||
|
'endpoint_not_found',
|
||||||
|
'context_overflow',
|
||||||
|
'tool_call_incompatible',
|
||||||
|
'malformed_provider_response',
|
||||||
|
'provider_unavailable',
|
||||||
|
'unknown',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isOpenAICompatibilityFailureCategory(
|
||||||
|
value: string,
|
||||||
|
): value is OpenAICompatibilityFailureCategory {
|
||||||
|
return OPENAI_COMPATIBILITY_FAILURE_CATEGORIES.has(
|
||||||
|
value as OpenAICompatibilityFailureCategory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: unknown): string | undefined {
|
||||||
|
let current: unknown = error
|
||||||
|
const maxDepth = 5
|
||||||
|
|
||||||
|
for (let depth = 0; depth < maxDepth; depth++) {
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
typeof current === 'object' &&
|
||||||
|
'code' in current &&
|
||||||
|
typeof (current as { code?: unknown }).code === 'string'
|
||||||
|
) {
|
||||||
|
return (current as { code: string }).code
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
typeof current === 'object' &&
|
||||||
|
'cause' in current &&
|
||||||
|
(current as { cause?: unknown }).cause !== current
|
||||||
|
) {
|
||||||
|
current = (current as { cause?: unknown }).cause
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHostname(url: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.toLowerCase()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalhostLikeHostname(hostname: string | null): boolean {
|
||||||
|
if (!hostname) return false
|
||||||
|
if (LOCALHOST_HOSTNAMES.has(hostname)) return true
|
||||||
|
return /^127\./.test(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContextOverflowMessage(body: string): boolean {
|
||||||
|
const lower = body.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('too many tokens') ||
|
||||||
|
lower.includes('request too large') ||
|
||||||
|
lower.includes('context length') ||
|
||||||
|
lower.includes('maximum context') ||
|
||||||
|
lower.includes('input length') ||
|
||||||
|
lower.includes('payload too large') ||
|
||||||
|
lower.includes('prompt is too long')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolCompatibilityMessage(body: string): boolean {
|
||||||
|
const lower = body.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('tool_calls') ||
|
||||||
|
lower.includes('tool_call') ||
|
||||||
|
lower.includes('tool_use') ||
|
||||||
|
lower.includes('tool_result') ||
|
||||||
|
lower.includes('function calling') ||
|
||||||
|
lower.includes('function call')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMalformedProviderResponse(body: string): boolean {
|
||||||
|
const lower = body.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('<!doctype html') ||
|
||||||
|
lower.includes('<html') ||
|
||||||
|
lower.includes('invalid json') ||
|
||||||
|
lower.includes('malformed') ||
|
||||||
|
lower.includes('unexpected token') ||
|
||||||
|
lower.includes('cannot parse') ||
|
||||||
|
lower.includes('not valid json')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelNotFoundMessage(body: string): boolean {
|
||||||
|
const lower = body.toLowerCase()
|
||||||
|
return (
|
||||||
|
lower.includes('model') &&
|
||||||
|
(
|
||||||
|
lower.includes('not found') ||
|
||||||
|
lower.includes('does not exist') ||
|
||||||
|
lower.includes('unknown model') ||
|
||||||
|
lower.includes('unavailable model')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOpenAICategoryMarker(
|
||||||
|
category: OpenAICompatibilityFailureCategory,
|
||||||
|
): string {
|
||||||
|
return `${OPENAI_CATEGORY_MARKER_PREFIX}${category}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOpenAICategoryMarker(
|
||||||
|
message: string,
|
||||||
|
): OpenAICompatibilityFailureCategory | undefined {
|
||||||
|
const match = message.match(/\[openai_category=([a-z_]+)]/)
|
||||||
|
const category = match?.[1]
|
||||||
|
|
||||||
|
if (!category || !isOpenAICompatibilityFailureCategory(category)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenAICompatibilityErrorMessage(
|
||||||
|
baseMessage: string,
|
||||||
|
failure: Pick<OpenAICompatibilityFailure, 'category' | 'hint'>,
|
||||||
|
): string {
|
||||||
|
const marker = formatOpenAICategoryMarker(failure.category)
|
||||||
|
const hint = failure.hint ? ` Hint: ${failure.hint}` : ''
|
||||||
|
return `${baseMessage} ${marker}${hint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyOpenAINetworkFailure(
|
||||||
|
error: unknown,
|
||||||
|
options: { url: string },
|
||||||
|
): OpenAICompatibilityFailure {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
const lowerMessage = message.toLowerCase()
|
||||||
|
const code = getErrorCode(error)
|
||||||
|
const hostname = getHostname(options.url)
|
||||||
|
const isLocalHost = isLocalhostLikeHostname(hostname)
|
||||||
|
|
||||||
|
if (
|
||||||
|
code === 'ETIMEDOUT' ||
|
||||||
|
code === 'UND_ERR_CONNECT_TIMEOUT' ||
|
||||||
|
lowerMessage.includes('timeout') ||
|
||||||
|
lowerMessage.includes('timed out') ||
|
||||||
|
lowerMessage.includes('aborterror')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
source: 'network',
|
||||||
|
category: 'request_timeout',
|
||||||
|
retryable: true,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
hint: 'The provider took too long to respond. Check local model load time or increase API timeout.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLocalHost &&
|
||||||
|
(
|
||||||
|
code === 'ENOTFOUND' ||
|
||||||
|
code === 'EAI_AGAIN' ||
|
||||||
|
lowerMessage.includes('getaddrinfo') ||
|
||||||
|
(code === undefined && lowerMessage.includes('fetch failed'))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
source: 'network',
|
||||||
|
category: 'localhost_resolution_failed',
|
||||||
|
retryable: true,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
hint: 'Localhost failed for this request. Retry with 127.0.0.1 and confirm Ollama is serving on the configured port.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 'ECONNREFUSED') {
|
||||||
|
return {
|
||||||
|
source: 'network',
|
||||||
|
category: 'connection_refused',
|
||||||
|
retryable: true,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
hint: isLocalHost
|
||||||
|
? 'Connection to the local provider was refused. Ensure the local server is running and listening on the configured port.'
|
||||||
|
: 'Connection was refused by the provider endpoint. Ensure the server is running and the port is correct.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'network',
|
||||||
|
category: 'network_error',
|
||||||
|
retryable: true,
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
hint: 'Network transport failed before a provider response was received.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyOpenAIHttpFailure(options: {
|
||||||
|
status: number
|
||||||
|
body: string
|
||||||
|
}): OpenAICompatibilityFailure {
|
||||||
|
const body = options.body ?? ''
|
||||||
|
|
||||||
|
if (options.status === 401 || options.status === 403) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'auth_invalid',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Authentication failed. Verify API key, token source, and endpoint-specific auth headers.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === 429) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'rate_limited',
|
||||||
|
retryable: true,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Provider rate-limited the request. Retry after backoff.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === 404 && isModelNotFoundMessage(body)) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'model_not_found',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'The selected model is not installed or not available on this endpoint.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === 404) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'endpoint_not_found',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Endpoint was not found. Confirm OPENAI_BASE_URL includes /v1 for OpenAI-compatible local providers.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.status === 413 ||
|
||||||
|
((options.status === 400 || options.status >= 500) &&
|
||||||
|
isContextOverflowMessage(body))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'context_overflow',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Prompt context exceeded model/server limits. Reduce context or increase provider context length.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status === 400 && isToolCompatibilityMessage(body)) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'tool_call_incompatible',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Provider/model rejected tool-calling payload. Retry without tools or use a tool-capable model.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(options.status >= 200 && options.status < 300 && isMalformedProviderResponse(body)) ||
|
||||||
|
(options.status >= 400 && isMalformedProviderResponse(body))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'malformed_provider_response',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Provider returned malformed or non-JSON response where JSON was expected.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.status >= 500) {
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'provider_unavailable',
|
||||||
|
retryable: true,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
hint: 'Provider reported a server-side failure. Retry after a short delay.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'http',
|
||||||
|
category: 'unknown',
|
||||||
|
retryable: false,
|
||||||
|
status: options.status,
|
||||||
|
message: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/services/api/openaiShim.diagnostics.test.ts
Normal file
119
src/services/api/openaiShim.diagnostics.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { afterEach, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
const originalEnv = {
|
||||||
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||||
|
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||||
|
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logs classified transport diagnostics with category and code', async () => {
|
||||||
|
const debugSpy = mock(() => {})
|
||||||
|
mock.module('../../utils/debug.js', () => ({
|
||||||
|
logForDebugging: debugSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
|
||||||
|
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
|
process.env.OPENAI_API_KEY = 'ollama'
|
||||||
|
|
||||||
|
const transportError = Object.assign(new TypeError('fetch failed'), {
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.fetch = mock(async () => {
|
||||||
|
throw transportError
|
||||||
|
}) as typeof globalThis.fetch
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as {
|
||||||
|
beta: {
|
||||||
|
messages: {
|
||||||
|
create: (params: Record<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create({
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('openai_category=connection_refused')
|
||||||
|
|
||||||
|
const transportLog = debugSpy.mock.calls.find(call =>
|
||||||
|
typeof call?.[0] === 'string' && call[0].includes('transport failure'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transportLog).toBeDefined()
|
||||||
|
expect(String(transportLog?.[0])).toContain('category=connection_refused')
|
||||||
|
expect(String(transportLog?.[0])).toContain('code=ECONNREFUSED')
|
||||||
|
expect(transportLog?.[1]).toEqual({ level: 'warn' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redacts credentials in transport diagnostic URL logs', async () => {
|
||||||
|
const debugSpy = mock(() => {})
|
||||||
|
mock.module('../../utils/debug.js', () => ({
|
||||||
|
logForDebugging: debugSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`)
|
||||||
|
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://user:supersecret@localhost:11434/v1'
|
||||||
|
process.env.OPENAI_API_KEY = 'supersecret'
|
||||||
|
|
||||||
|
const transportError = Object.assign(new TypeError('fetch failed'), {
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.fetch = mock(async () => {
|
||||||
|
throw transportError
|
||||||
|
}) as typeof globalThis.fetch
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as {
|
||||||
|
beta: {
|
||||||
|
messages: {
|
||||||
|
create: (params: Record<string, unknown>) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create({
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('openai_category=connection_refused')
|
||||||
|
|
||||||
|
const transportLog = debugSpy.mock.calls.find(call =>
|
||||||
|
typeof call?.[0] === 'string' && call[0].includes('transport failure'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(transportLog).toBeDefined()
|
||||||
|
const logLine = String(transportLog?.[0])
|
||||||
|
expect(logLine).toContain('url=http://redacted:redacted@localhost:11434/v1/chat/completions')
|
||||||
|
expect(logLine).not.toContain('user:supersecret')
|
||||||
|
expect(logLine).not.toContain('supersecret@')
|
||||||
|
})
|
||||||
@@ -2775,3 +2775,84 @@ test('streaming: strips leaked reasoning preamble when split across multiple con
|
|||||||
|
|
||||||
expect(textDeltas).toEqual(['Hey! How can I help you today?'])
|
expect(textDeltas).toEqual(['Hey! How can I help you today?'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('classifies localhost transport failures with actionable category marker', async () => {
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
|
|
||||||
|
const transportError = Object.assign(new TypeError('fetch failed'), {
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
})
|
||||||
|
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
throw transportError
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create({
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('openai_category=connection_refused')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create({
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('local server is running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('propagates AbortError without wrapping it as transport failure', async () => {
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
||||||
|
|
||||||
|
const abortError = new DOMException('The operation was aborted.', 'AbortError')
|
||||||
|
globalThis.fetch = (async () => {
|
||||||
|
throw abortError
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
controller.abort()
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create(
|
||||||
|
{
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
),
|
||||||
|
).rejects.toBe(abortError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => {
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434'
|
||||||
|
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response('Not Found', {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})) as FetchType
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.beta.messages.create({
|
||||||
|
model: 'qwen2.5-coder:7b',
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
max_tokens: 64,
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('openai_category=endpoint_not_found')
|
||||||
|
})
|
||||||
|
|||||||
@@ -47,12 +47,18 @@ import {
|
|||||||
type AnthropicUsage,
|
type AnthropicUsage,
|
||||||
type ShimCreateParams,
|
type ShimCreateParams,
|
||||||
} from './codexShim.js'
|
} from './codexShim.js'
|
||||||
|
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
|
||||||
import {
|
import {
|
||||||
isLocalProviderUrl,
|
isLocalProviderUrl,
|
||||||
resolveRuntimeCodexCredentials,
|
resolveRuntimeCodexCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
getGithubEndpointType,
|
getGithubEndpointType,
|
||||||
} from './providerConfig.js'
|
} from './providerConfig.js'
|
||||||
|
import {
|
||||||
|
buildOpenAICompatibilityErrorMessage,
|
||||||
|
classifyOpenAIHttpFailure,
|
||||||
|
classifyOpenAINetworkFailure,
|
||||||
|
} from './openaiErrorClassification.js'
|
||||||
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
||||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +88,19 @@ const COPILOT_HEADERS: Record<string, string> = {
|
|||||||
'Copilot-Integration-Id': 'vscode-chat',
|
'Copilot-Integration-Id': 'vscode-chat',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_URL_QUERY_PARAM_NAMES = [
|
||||||
|
'api_key',
|
||||||
|
'key',
|
||||||
|
'token',
|
||||||
|
'access_token',
|
||||||
|
'refresh_token',
|
||||||
|
'signature',
|
||||||
|
'sig',
|
||||||
|
'secret',
|
||||||
|
'password',
|
||||||
|
'authorization',
|
||||||
|
]
|
||||||
|
|
||||||
function isGithubModelsMode(): boolean {
|
function isGithubModelsMode(): boolean {
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
}
|
}
|
||||||
@@ -131,6 +150,34 @@ function formatRetryAfterHint(response: Response): string {
|
|||||||
return ra ? ` (Retry-After: ${ra})` : ''
|
return ra ? ` (Retry-After: ${ra})` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRedactUrlQueryParam(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
return SENSITIVE_URL_QUERY_PARAM_NAMES.some(token => lower.includes(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactUrlForDiagnostics(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
if (parsed.username) {
|
||||||
|
parsed.username = 'redacted'
|
||||||
|
}
|
||||||
|
if (parsed.password) {
|
||||||
|
parsed.password = 'redacted'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of parsed.searchParams.keys()) {
|
||||||
|
if (shouldRedactUrlQueryParam(key)) {
|
||||||
|
parsed.searchParams.set(key, 'redacted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = parsed.toString()
|
||||||
|
return redactSecretValueForDisplay(serialized, process.env as SecretValueSource) ?? serialized
|
||||||
|
} catch {
|
||||||
|
return redactSecretValueForDisplay(url, process.env as SecretValueSource) ?? url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sleepMs(ms: number): Promise<void> {
|
function sleepMs(ms: number): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
@@ -1360,8 +1407,12 @@ class OpenAIShimMessages {
|
|||||||
...filterAnthropicHeaders(options?.headers),
|
...filterAnthropicHeaders(options?.headers),
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGemini = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
const isGemini = isGeminiMode()
|
||||||
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
|
const isMiniMax = !!process.env.MINIMAX_API_KEY
|
||||||
|
const apiKey =
|
||||||
|
this.providerOverride?.apiKey ??
|
||||||
|
process.env.OPENAI_API_KEY ??
|
||||||
|
(isMiniMax ? process.env.MINIMAX_API_KEY : '')
|
||||||
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
// Detect Azure endpoints by hostname (not raw URL) to prevent bypass via
|
||||||
// path segments like https://evil.com/cognitiveservices.azure.com/
|
// path segments like https://evil.com/cognitiveservices.azure.com/
|
||||||
let isAzure = false
|
let isAzure = false
|
||||||
@@ -1425,12 +1476,97 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
|
const maxAttempts = isGithub ? GITHUB_429_MAX_RETRIES : 1
|
||||||
|
|
||||||
|
const throwClassifiedTransportError = (
|
||||||
|
error: unknown,
|
||||||
|
requestUrl: string,
|
||||||
|
): never => {
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const failure = classifyOpenAINetworkFailure(error, {
|
||||||
|
url: requestUrl,
|
||||||
|
})
|
||||||
|
const redactedUrl = redactUrlForDiagnostics(requestUrl)
|
||||||
|
const safeMessage =
|
||||||
|
redactSecretValueForDisplay(
|
||||||
|
failure.message,
|
||||||
|
process.env as SecretValueSource,
|
||||||
|
) || 'Request failed'
|
||||||
|
|
||||||
|
logForDebugging(
|
||||||
|
`[OpenAIShim] transport failure category=${failure.category} retryable=${failure.retryable} code=${failure.code ?? 'unknown'} method=POST url=${redactedUrl} model=${request.resolvedModel} message=${safeMessage}`,
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
|
||||||
|
throw APIError.generate(
|
||||||
|
503,
|
||||||
|
undefined,
|
||||||
|
buildOpenAICompatibilityErrorMessage(
|
||||||
|
`OpenAI API transport error: ${safeMessage}${failure.code ? ` (code=${failure.code})` : ''}`,
|
||||||
|
failure,
|
||||||
|
),
|
||||||
|
new Headers(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const throwClassifiedHttpError = (
|
||||||
|
status: number,
|
||||||
|
errorBody: string,
|
||||||
|
parsedBody: object | undefined,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
requestUrl: string,
|
||||||
|
rateHint = '',
|
||||||
|
): never => {
|
||||||
|
const failure = classifyOpenAIHttpFailure({
|
||||||
|
status,
|
||||||
|
body: errorBody,
|
||||||
|
})
|
||||||
|
const redactedUrl = redactUrlForDiagnostics(requestUrl)
|
||||||
|
|
||||||
|
logForDebugging(
|
||||||
|
`[OpenAIShim] request failed category=${failure.category} retryable=${failure.retryable} status=${status} method=POST url=${redactedUrl} model=${request.resolvedModel}`,
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
|
||||||
|
throw APIError.generate(
|
||||||
|
status,
|
||||||
|
parsedBody,
|
||||||
|
buildOpenAICompatibilityErrorMessage(
|
||||||
|
`OpenAI API error ${status}: ${errorBody}${rateHint}`,
|
||||||
|
failure,
|
||||||
|
),
|
||||||
|
responseHeaders,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let response: Response | undefined
|
let response: Response | undefined
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
response = await fetch(chatCompletionsUrl, fetchInit)
|
try {
|
||||||
|
response = await fetchWithProxyRetry(chatCompletionsUrl, fetchInit)
|
||||||
|
} catch (error) {
|
||||||
|
const isAbortError =
|
||||||
|
fetchInit.signal?.aborted === true ||
|
||||||
|
(typeof DOMException !== 'undefined' &&
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'AbortError') ||
|
||||||
|
(typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'name' in error &&
|
||||||
|
error.name === 'AbortError')
|
||||||
|
|
||||||
|
if (isAbortError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
throwClassifiedTransportError(error, chatCompletionsUrl)
|
||||||
|
}
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isGithub &&
|
isGithub &&
|
||||||
response.status === 429 &&
|
response.status === 429 &&
|
||||||
@@ -1500,34 +1636,43 @@ class OpenAIShimMessages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesResponse = await fetch(responsesUrl, {
|
let responsesResponse: Response
|
||||||
method: 'POST',
|
try {
|
||||||
headers,
|
responsesResponse = await fetchWithProxyRetry(responsesUrl, {
|
||||||
body: JSON.stringify(responsesBody),
|
method: 'POST',
|
||||||
signal: options?.signal,
|
headers,
|
||||||
})
|
body: JSON.stringify(responsesBody),
|
||||||
|
signal: options?.signal,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throwClassifiedTransportError(error, responsesUrl)
|
||||||
|
}
|
||||||
|
|
||||||
if (responsesResponse.ok) {
|
if (responsesResponse.ok) {
|
||||||
return responsesResponse
|
return responsesResponse
|
||||||
}
|
}
|
||||||
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
|
const responsesErrorBody = await responsesResponse.text().catch(() => 'unknown error')
|
||||||
let responsesErrorResponse: object | undefined
|
let responsesErrorResponse: object | undefined
|
||||||
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
|
try { responsesErrorResponse = JSON.parse(responsesErrorBody) } catch { /* raw text */ }
|
||||||
throw APIError.generate(
|
throwClassifiedHttpError(
|
||||||
responsesResponse.status,
|
responsesResponse.status,
|
||||||
|
responsesErrorBody,
|
||||||
responsesErrorResponse,
|
responsesErrorResponse,
|
||||||
`OpenAI API error ${responsesResponse.status}: ${responsesErrorBody}`,
|
|
||||||
responsesResponse.headers,
|
responsesResponse.headers,
|
||||||
|
responsesUrl,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let errorResponse: object | undefined
|
let errorResponse: object | undefined
|
||||||
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
|
try { errorResponse = JSON.parse(errorBody) } catch { /* raw text */ }
|
||||||
throw APIError.generate(
|
throwClassifiedHttpError(
|
||||||
response.status,
|
response.status,
|
||||||
|
errorBody,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
`OpenAI API error ${response.status}: ${errorBody}${rateHint}`,
|
|
||||||
response.headers as unknown as Headers,
|
response.headers as unknown as Headers,
|
||||||
|
chatCompletionsUrl,
|
||||||
|
rateHint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
src/services/api/providerConfig.envDiagnostics.test.ts
Normal file
107
src/services/api/providerConfig.envDiagnostics.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { afterEach, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
const originalEnv = {
|
||||||
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
|
CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL,
|
||||||
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||||
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
|
||||||
|
MISTRAL_BASE_URL: process.env.MISTRAL_BASE_URL,
|
||||||
|
MISTRAL_MODEL: process.env.MISTRAL_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(key: string, value: string | undefined): void {
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[key]
|
||||||
|
} else {
|
||||||
|
process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||||
|
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||||
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||||
|
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||||
|
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
|
||||||
|
restoreEnv('MISTRAL_BASE_URL', originalEnv.MISTRAL_BASE_URL)
|
||||||
|
restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL)
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logs a warning when OPENAI_BASE_URL is literal undefined', async () => {
|
||||||
|
const debugSpy = mock(() => {})
|
||||||
|
mock.module('../../utils/debug.js', () => ({
|
||||||
|
logForDebugging: debugSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL = 'undefined'
|
||||||
|
process.env.OPENAI_MODEL = 'gpt-4o'
|
||||||
|
delete process.env.OPENAI_API_BASE
|
||||||
|
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
|
||||||
|
|
||||||
|
const resolved = resolveProviderRequest()
|
||||||
|
|
||||||
|
expect(resolved.baseUrl).toBe('https://api.openai.com/v1')
|
||||||
|
|
||||||
|
const warningCall = debugSpy.mock.calls.find(call =>
|
||||||
|
typeof call?.[0] === 'string' &&
|
||||||
|
call[0].includes('OPENAI_BASE_URL') &&
|
||||||
|
call[0].includes('"undefined"'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(warningCall).toBeDefined()
|
||||||
|
expect(warningCall?.[1]).toEqual({ level: 'warn' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not warn for OPENAI_API_BASE when OPENAI_BASE_URL is active', async () => {
|
||||||
|
const debugSpy = mock(() => {})
|
||||||
|
mock.module('../../utils/debug.js', () => ({
|
||||||
|
logForDebugging: debugSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_USE_MISTRAL
|
||||||
|
process.env.OPENAI_BASE_URL = 'http://127.0.0.1:11434/v1'
|
||||||
|
process.env.OPENAI_MODEL = 'qwen2.5-coder:7b'
|
||||||
|
process.env.OPENAI_API_BASE = 'undefined'
|
||||||
|
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
|
||||||
|
|
||||||
|
const resolved = resolveProviderRequest()
|
||||||
|
|
||||||
|
expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1')
|
||||||
|
|
||||||
|
const aliasWarning = debugSpy.mock.calls.find(call =>
|
||||||
|
typeof call?.[0] === 'string' &&
|
||||||
|
call[0].includes('OPENAI_API_BASE') &&
|
||||||
|
call[0].includes('"undefined"'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(aliasWarning).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses OPENAI_API_BASE as fallback in mistral mode when MISTRAL_BASE_URL is unset', async () => {
|
||||||
|
const debugSpy = mock(() => {})
|
||||||
|
mock.module('../../utils/debug.js', () => ({
|
||||||
|
logForDebugging: debugSpy,
|
||||||
|
}))
|
||||||
|
|
||||||
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
process.env.CLAUDE_CODE_USE_MISTRAL = '1'
|
||||||
|
delete process.env.MISTRAL_BASE_URL
|
||||||
|
process.env.MISTRAL_MODEL = 'mistral-medium-latest'
|
||||||
|
process.env.OPENAI_API_BASE = 'http://127.0.0.1:11434/v1'
|
||||||
|
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { resolveProviderRequest } = await import(`./providerConfig.ts?ts=${nonce}`)
|
||||||
|
|
||||||
|
const resolved = resolveProviderRequest()
|
||||||
|
|
||||||
|
expect(resolved.baseUrl).toBe('http://127.0.0.1:11434/v1')
|
||||||
|
expect(debugSpy.mock.calls).toHaveLength(0)
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
readCodexCredentials,
|
readCodexCredentials,
|
||||||
type CodexCredentialBlob,
|
type CodexCredentialBlob,
|
||||||
} from '../../utils/codexCredentials.js'
|
} from '../../utils/codexCredentials.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||||
import {
|
import {
|
||||||
asTrimmedString,
|
asTrimmedString,
|
||||||
@@ -19,6 +20,7 @@ export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
|||||||
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
|
export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1'
|
||||||
/** Default GitHub Copilot API model when user selects copilot / github:copilot */
|
/** Default GitHub Copilot API model when user selects copilot / github:copilot */
|
||||||
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
|
export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o'
|
||||||
|
const warnedUndefinedEnvNames = new Set<string>()
|
||||||
|
|
||||||
const CODEX_ALIAS_MODELS: Record<
|
const CODEX_ALIAS_MODELS: Record<
|
||||||
string,
|
string,
|
||||||
@@ -129,7 +131,33 @@ function isPrivateIpv6Address(hostname: string): boolean {
|
|||||||
function asEnvUrl(value: string | undefined): string | undefined {
|
function asEnvUrl(value: string | undefined): string | undefined {
|
||||||
if (!value) return undefined
|
if (!value) return undefined
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed || trimmed === 'undefined') return undefined
|
if (!trimmed) return undefined
|
||||||
|
if (trimmed === 'undefined') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNamedEnvUrl(
|
||||||
|
value: string | undefined,
|
||||||
|
envName: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
|
||||||
|
if (trimmed === 'undefined') {
|
||||||
|
if (!warnedUndefinedEnvNames.has(envName)) {
|
||||||
|
warnedUndefinedEnvNames.add(envName)
|
||||||
|
logForDebugging(
|
||||||
|
`[provider-config] Environment variable ${envName} is the literal string "undefined"; ignoring it.`,
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +390,28 @@ export function resolveProviderRequest(options?: {
|
|||||||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
(isGithubMode ? 'github:copilot' : 'gpt-4o')
|
||||||
const descriptor = parseModelDescriptor(requestedModel)
|
const descriptor = parseModelDescriptor(requestedModel)
|
||||||
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
const explicitBaseUrl = asEnvUrl(options?.baseUrl)
|
||||||
|
|
||||||
|
const normalizedMistralEnvBaseUrl = asNamedEnvUrl(
|
||||||
|
process.env.MISTRAL_BASE_URL,
|
||||||
|
'MISTRAL_BASE_URL',
|
||||||
|
)
|
||||||
|
|
||||||
|
const primaryEnvBaseUrl = isMistralMode
|
||||||
|
? normalizedMistralEnvBaseUrl
|
||||||
|
: asNamedEnvUrl(process.env.OPENAI_BASE_URL, 'OPENAI_BASE_URL')
|
||||||
|
|
||||||
|
const fallbackEnvBaseUrl = isMistralMode
|
||||||
|
? (primaryEnvBaseUrl === undefined
|
||||||
|
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_MISTRAL_BASE_URL
|
||||||
|
: undefined)
|
||||||
|
: (primaryEnvBaseUrl === undefined
|
||||||
|
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE')
|
||||||
|
: undefined)
|
||||||
|
|
||||||
const envBaseUrlRaw =
|
const envBaseUrlRaw =
|
||||||
explicitBaseUrl ??
|
explicitBaseUrl ??
|
||||||
asEnvUrl(
|
primaryEnvBaseUrl ??
|
||||||
isMistralMode
|
fallbackEnvBaseUrl
|
||||||
? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL)
|
|
||||||
: process.env.OPENAI_BASE_URL
|
|
||||||
) ??
|
|
||||||
asEnvUrl(process.env.OPENAI_API_BASE)
|
|
||||||
|
|
||||||
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
|
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
|
||||||
const envBaseUrl =
|
const envBaseUrl =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_GITHUB_DEVICE_SCOPE,
|
DEFAULT_GITHUB_DEVICE_SCOPE,
|
||||||
@@ -12,22 +12,15 @@ async function importFreshModule() {
|
|||||||
return import(`./deviceFlow.ts?ts=${Date.now()}-${Math.random()}`)
|
return import(`./deviceFlow.ts?ts=${Date.now()}-${Math.random()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
describe('requestDeviceCode', () => {
|
describe('requestDeviceCode', () => {
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mock.restore()
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
})
|
|
||||||
|
|
||||||
test('parses successful device code response', async () => {
|
test('parses successful device code response', async () => {
|
||||||
const { requestDeviceCode } = await importFreshModule()
|
const { requestDeviceCode } = await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -44,7 +37,7 @@ describe('requestDeviceCode', () => {
|
|||||||
|
|
||||||
const r = await requestDeviceCode({
|
const r = await requestDeviceCode({
|
||||||
clientId: 'test-client',
|
clientId: 'test-client',
|
||||||
fetchImpl: globalThis.fetch,
|
fetchImpl,
|
||||||
})
|
})
|
||||||
expect(r.device_code).toBe('abc')
|
expect(r.device_code).toBe('abc')
|
||||||
expect(r.user_code).toBe('ABCD-1234')
|
expect(r.user_code).toBe('ABCD-1234')
|
||||||
@@ -57,17 +50,17 @@ describe('requestDeviceCode', () => {
|
|||||||
const { requestDeviceCode, GitHubDeviceFlowError } =
|
const { requestDeviceCode, GitHubDeviceFlowError } =
|
||||||
await importFreshModule()
|
await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(new Response('bad', { status: 500 })),
|
Promise.resolve(new Response('bad', { status: 500 })),
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
requestDeviceCode({ clientId: 'x', fetchImpl: globalThis.fetch }),
|
requestDeviceCode({ clientId: 'x', fetchImpl }),
|
||||||
).rejects.toThrow(GitHubDeviceFlowError)
|
).rejects.toThrow(GitHubDeviceFlowError)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('uses OAuth-safe default scope', async () => {
|
test('uses OAuth-safe default scope', async () => {
|
||||||
let capturedScope = ''
|
let capturedScope = ''
|
||||||
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
const fetchImpl = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const body = init?.body
|
const body = init?.body
|
||||||
if (body instanceof URLSearchParams) {
|
if (body instanceof URLSearchParams) {
|
||||||
capturedScope = body.get('scope') ?? ''
|
capturedScope = body.get('scope') ?? ''
|
||||||
@@ -87,7 +80,7 @@ describe('requestDeviceCode', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
await requestDeviceCode({ clientId: 'test-client', fetchImpl: globalThis.fetch })
|
await requestDeviceCode({ clientId: 'test-client', fetchImpl })
|
||||||
expect(capturedScope).toBe(DEFAULT_GITHUB_DEVICE_SCOPE)
|
expect(capturedScope).toBe(DEFAULT_GITHUB_DEVICE_SCOPE)
|
||||||
expect(capturedScope).toBe('read:user')
|
expect(capturedScope).toBe('read:user')
|
||||||
})
|
})
|
||||||
@@ -96,7 +89,7 @@ describe('requestDeviceCode', () => {
|
|||||||
const scopesSeen: string[] = []
|
const scopesSeen: string[] = []
|
||||||
let callCount = 0
|
let callCount = 0
|
||||||
|
|
||||||
globalThis.fetch = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
const fetchImpl = mock((_url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const body = init?.body
|
const body = init?.body
|
||||||
const scope =
|
const scope =
|
||||||
body instanceof URLSearchParams
|
body instanceof URLSearchParams
|
||||||
@@ -132,7 +125,7 @@ describe('requestDeviceCode', () => {
|
|||||||
const result = await requestDeviceCode({
|
const result = await requestDeviceCode({
|
||||||
clientId: 'test-client',
|
clientId: 'test-client',
|
||||||
scope: 'read:user,models:read',
|
scope: 'read:user,models:read',
|
||||||
fetchImpl: globalThis.fetch,
|
fetchImpl,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.device_code).toBe('abc')
|
expect(result.device_code).toBe('abc')
|
||||||
@@ -142,17 +135,11 @@ describe('requestDeviceCode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('pollAccessToken', () => {
|
describe('pollAccessToken', () => {
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
})
|
|
||||||
|
|
||||||
test('returns token when GitHub responds with access_token immediately', async () => {
|
test('returns token when GitHub responds with access_token immediately', async () => {
|
||||||
const { pollAccessToken } = await importFreshModule()
|
const { pollAccessToken } = await importFreshModule()
|
||||||
|
|
||||||
let calls = 0
|
let calls = 0
|
||||||
globalThis.fetch = mock(() => {
|
const fetchImpl = mock(() => {
|
||||||
calls++
|
calls++
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
new Response(JSON.stringify({ access_token: 'tok-xyz' }), {
|
new Response(JSON.stringify({ access_token: 'tok-xyz' }), {
|
||||||
@@ -163,7 +150,7 @@ describe('pollAccessToken', () => {
|
|||||||
|
|
||||||
const token = await pollAccessToken('dev-code', {
|
const token = await pollAccessToken('dev-code', {
|
||||||
clientId: 'cid',
|
clientId: 'cid',
|
||||||
fetchImpl: globalThis.fetch,
|
fetchImpl,
|
||||||
})
|
})
|
||||||
expect(token).toBe('tok-xyz')
|
expect(token).toBe('tok-xyz')
|
||||||
expect(calls).toBe(1)
|
expect(calls).toBe(1)
|
||||||
@@ -172,7 +159,7 @@ describe('pollAccessToken', () => {
|
|||||||
test('throws on access_denied', async () => {
|
test('throws on access_denied', async () => {
|
||||||
const { pollAccessToken } = await importFreshModule()
|
const { pollAccessToken } = await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
new Response(JSON.stringify({ error: 'access_denied' }), {
|
new Response(JSON.stringify({ error: 'access_denied' }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -182,23 +169,17 @@ describe('pollAccessToken', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
pollAccessToken('dc', {
|
pollAccessToken('dc', {
|
||||||
clientId: 'c',
|
clientId: 'c',
|
||||||
fetchImpl: globalThis.fetch,
|
fetchImpl,
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/denied/)
|
).rejects.toThrow(/denied/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('exchangeForCopilotToken', () => {
|
describe('exchangeForCopilotToken', () => {
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
})
|
|
||||||
|
|
||||||
test('parses successful Copilot token response', async () => {
|
test('parses successful Copilot token response', async () => {
|
||||||
const { exchangeForCopilotToken } = await importFreshModule()
|
const { exchangeForCopilotToken } = await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
new Response(
|
new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -214,7 +195,7 @@ describe('exchangeForCopilotToken', () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await exchangeForCopilotToken('oauth-token', globalThis.fetch)
|
const result = await exchangeForCopilotToken('oauth-token', fetchImpl)
|
||||||
expect(result.token).toBe('copilot-token-xyz')
|
expect(result.token).toBe('copilot-token-xyz')
|
||||||
expect(result.expires_at).toBe(1700000000)
|
expect(result.expires_at).toBe(1700000000)
|
||||||
expect(result.refresh_in).toBe(3600)
|
expect(result.refresh_in).toBe(3600)
|
||||||
@@ -225,24 +206,24 @@ describe('exchangeForCopilotToken', () => {
|
|||||||
const { exchangeForCopilotToken, GitHubDeviceFlowError } =
|
const { exchangeForCopilotToken, GitHubDeviceFlowError } =
|
||||||
await importFreshModule()
|
await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(new Response('unauthorized', { status: 401 })),
|
Promise.resolve(new Response('unauthorized', { status: 401 })),
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
exchangeForCopilotToken('bad-token', globalThis.fetch),
|
exchangeForCopilotToken('bad-token', fetchImpl),
|
||||||
).rejects.toThrow(GitHubDeviceFlowError)
|
).rejects.toThrow(GitHubDeviceFlowError)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('throws on malformed response', async () => {
|
test('throws on malformed response', async () => {
|
||||||
const { exchangeForCopilotToken } = await importFreshModule()
|
const { exchangeForCopilotToken } = await importFreshModule()
|
||||||
|
|
||||||
globalThis.fetch = mock(() =>
|
const fetchImpl = mock(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
new Response(JSON.stringify({ invalid: 'data' }), { status: 200 }),
|
new Response(JSON.stringify({ invalid: 'data' }), { status: 200 }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await expect(
|
await expect(
|
||||||
exchangeForCopilotToken('oauth-token', globalThis.fetch),
|
exchangeForCopilotToken('oauth-token', fetchImpl),
|
||||||
).rejects.toThrow(/Malformed/)
|
).rejects.toThrow(/Malformed/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { Ajv } from 'ajv'
|
||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
import { buildTool, type ToolDef } from '../../Tool.js'
|
import { buildTool, type ToolDef, type ValidationResult } from '../../Tool.js'
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
|
import type { PermissionResult } from '../../types/permissions.js'
|
||||||
import { isOutputLineTruncated } from '../../utils/terminal.js'
|
import { isOutputLineTruncated } from '../../utils/terminal.js'
|
||||||
import { DESCRIPTION, PROMPT } from './prompt.js'
|
import { DESCRIPTION, PROMPT } from './prompt.js'
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,8 @@ export type Output = z.infer<OutputSchema>
|
|||||||
// Re-export MCPProgress from centralized types to break import cycles
|
// Re-export MCPProgress from centralized types to break import cycles
|
||||||
export type { MCPProgress } from '../../types/tools.js'
|
export type { MCPProgress } from '../../types/tools.js'
|
||||||
|
|
||||||
|
const ajv = new Ajv({ strict: false })
|
||||||
|
|
||||||
export const MCPTool = buildTool({
|
export const MCPTool = buildTool({
|
||||||
isMcp: true,
|
isMcp: true,
|
||||||
// Overridden in mcpClient.ts with the real MCP tool name + args
|
// Overridden in mcpClient.ts with the real MCP tool name + args
|
||||||
@@ -72,6 +75,27 @@ export const MCPTool = buildTool({
|
|||||||
message: 'MCPTool requires permission.',
|
message: 'MCPTool requires permission.',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async validateInput(input, context): Promise<ValidationResult> {
|
||||||
|
if (this.inputJSONSchema) {
|
||||||
|
try {
|
||||||
|
const validate = ajv.compile(this.inputJSONSchema)
|
||||||
|
if (!validate(input)) {
|
||||||
|
return {
|
||||||
|
result: false,
|
||||||
|
message: ajv.errorsText(validate.errors),
|
||||||
|
errorCode: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
result: false,
|
||||||
|
message: `Failed to compile JSON schema for validation: ${error}`,
|
||||||
|
errorCode: 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { result: true }
|
||||||
|
},
|
||||||
renderToolUseMessage,
|
renderToolUseMessage,
|
||||||
// Overridden in mcpClient.ts
|
// Overridden in mcpClient.ts
|
||||||
userFacingName: () => 'mcp',
|
userFacingName: () => 'mcp',
|
||||||
@@ -100,3 +124,4 @@ export const MCPTool = buildTool({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
} satisfies ToolDef<InputSchema, Output>)
|
} satisfies ToolDef<InputSchema, Output>)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from 'zod/v4'
|
|||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||||
import { queryModelWithStreaming } from '../../services/api/claude.js'
|
import { queryModelWithStreaming } from '../../services/api/claude.js'
|
||||||
import { collectCodexCompletedResponse } from '../../services/api/codexShim.js'
|
import { collectCodexCompletedResponse } from '../../services/api/codexShim.js'
|
||||||
|
import { fetchWithProxyRetry } from '../../services/api/fetchWithProxyRetry.js'
|
||||||
import {
|
import {
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
@@ -314,7 +315,7 @@ async function runCodexWebSearch(
|
|||||||
body.reasoning = request.reasoning
|
body.reasoning = request.reasoning
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${request.baseUrl}/responses`, {
|
const response = await fetchWithProxyRetry(`${request.baseUrl}/responses`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS,
|
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS,
|
||||||
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -23,11 +24,17 @@ afterEach(() => {
|
|||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS =
|
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS =
|
||||||
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
}
|
}
|
||||||
|
if (originalEnv.OPENAI_MODEL === undefined) {
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
} else {
|
||||||
|
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deepseek-chat uses provider-specific context and output caps', () => {
|
test('deepseek-chat uses provider-specific context and output caps', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('deepseek-chat')).toBe(128_000)
|
expect(getContextWindowForModel('deepseek-chat')).toBe(128_000)
|
||||||
expect(getModelMaxOutputTokens('deepseek-chat')).toEqual({
|
expect(getModelMaxOutputTokens('deepseek-chat')).toEqual({
|
||||||
@@ -40,6 +47,7 @@ test('deepseek-chat uses provider-specific context and output caps', () => {
|
|||||||
test('deepseek-chat clamps oversized max output overrides to the provider limit', () => {
|
test('deepseek-chat clamps oversized max output overrides to the provider limit', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '32000'
|
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '32000'
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getMaxOutputTokensForModel('deepseek-chat')).toBe(8_192)
|
expect(getMaxOutputTokensForModel('deepseek-chat')).toBe(8_192)
|
||||||
})
|
})
|
||||||
@@ -47,6 +55,7 @@ test('deepseek-chat clamps oversized max output overrides to the provider limit'
|
|||||||
test('gpt-4o uses provider-specific context and output caps', () => {
|
test('gpt-4o uses provider-specific context and output caps', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('gpt-4o')).toBe(128_000)
|
expect(getContextWindowForModel('gpt-4o')).toBe(128_000)
|
||||||
expect(getModelMaxOutputTokens('gpt-4o')).toEqual({
|
expect(getModelMaxOutputTokens('gpt-4o')).toEqual({
|
||||||
@@ -59,6 +68,7 @@ test('gpt-4o uses provider-specific context and output caps', () => {
|
|||||||
test('gpt-4o clamps oversized max output overrides to the provider limit', () => {
|
test('gpt-4o clamps oversized max output overrides to the provider limit', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '32000'
|
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '32000'
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16_384)
|
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16_384)
|
||||||
})
|
})
|
||||||
@@ -66,6 +76,7 @@ test('gpt-4o clamps oversized max output overrides to the provider limit', () =>
|
|||||||
test('gpt-5.4 family uses provider-specific context and output caps', () => {
|
test('gpt-5.4 family uses provider-specific context and output caps', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('gpt-5.4')).toBe(1_050_000)
|
expect(getContextWindowForModel('gpt-5.4')).toBe(1_050_000)
|
||||||
expect(getModelMaxOutputTokens('gpt-5.4')).toEqual({
|
expect(getModelMaxOutputTokens('gpt-5.4')).toEqual({
|
||||||
@@ -98,6 +109,7 @@ test('gpt-5.4 family keeps large max output overrides within provider limits', (
|
|||||||
test('MiniMax-M2.7 uses explicit provider-specific context and output caps', () => {
|
test('MiniMax-M2.7 uses explicit provider-specific context and output caps', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
|
expect(getContextWindowForModel('MiniMax-M2.7')).toBe(204_800)
|
||||||
expect(getModelMaxOutputTokens('MiniMax-M2.7')).toEqual({
|
expect(getModelMaxOutputTokens('MiniMax-M2.7')).toEqual({
|
||||||
@@ -110,6 +122,7 @@ test('MiniMax-M2.7 uses explicit provider-specific context and output caps', ()
|
|||||||
test('unknown openai-compatible models use the 128k fallback window (not 8k, see #635)', () => {
|
test('unknown openai-compatible models use the 128k fallback window (not 8k, see #635)', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('some-unknown-3p-model')).toBe(128_000)
|
expect(getContextWindowForModel('some-unknown-3p-model')).toBe(128_000)
|
||||||
})
|
})
|
||||||
@@ -117,6 +130,7 @@ test('unknown openai-compatible models use the 128k fallback window (not 8k, see
|
|||||||
test('MiniMax-M2.5 and M2.1 use explicit provider-specific context and output caps', () => {
|
test('MiniMax-M2.5 and M2.1 use explicit provider-specific context and output caps', () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
|
||||||
expect(getContextWindowForModel('MiniMax-M2.5')).toBe(204_800)
|
expect(getContextWindowForModel('MiniMax-M2.5')).toBe(204_800)
|
||||||
expect(getContextWindowForModel('MiniMax-M2.5-highspeed')).toBe(204_800)
|
expect(getContextWindowForModel('MiniMax-M2.5-highspeed')).toBe(204_800)
|
||||||
@@ -127,3 +141,116 @@ test('MiniMax-M2.5 and M2.1 use explicit provider-specific context and output ca
|
|||||||
upperLimit: 131_072,
|
upperLimit: 131_072,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3.6-plus uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3.6-plus')).toBe(1_000_000)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3.6-plus')).toEqual({
|
||||||
|
default: 65_536,
|
||||||
|
upperLimit: 65_536,
|
||||||
|
})
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3.6-plus')).toBe(65_536)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3.5-plus uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3.5-plus')).toBe(1_000_000)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3.5-plus')).toEqual({
|
||||||
|
default: 65_536,
|
||||||
|
upperLimit: 65_536,
|
||||||
|
})
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3.5-plus')).toBe(65_536)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3-coder-plus uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3-coder-plus')).toBe(1_000_000)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3-coder-plus')).toEqual({
|
||||||
|
default: 65_536,
|
||||||
|
upperLimit: 65_536,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3-coder-next uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3-coder-next')).toBe(262_144)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3-coder-next')).toEqual({
|
||||||
|
default: 65_536,
|
||||||
|
upperLimit: 65_536,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3-max uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3-max')).toBe(262_144)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3-max')).toEqual({
|
||||||
|
default: 32_768,
|
||||||
|
upperLimit: 32_768,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope qwen3-max dated variant resolves to base entry via prefix match', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('qwen3-max-2026-01-23')).toBe(262_144)
|
||||||
|
expect(getModelMaxOutputTokens('qwen3-max-2026-01-23')).toEqual({
|
||||||
|
default: 32_768,
|
||||||
|
upperLimit: 32_768,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope kimi-k2.5 uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('kimi-k2.5')).toBe(262_144)
|
||||||
|
expect(getModelMaxOutputTokens('kimi-k2.5')).toEqual({
|
||||||
|
default: 32_768,
|
||||||
|
upperLimit: 32_768,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope glm-5 uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('glm-5')).toBe(202_752)
|
||||||
|
expect(getModelMaxOutputTokens('glm-5')).toEqual({
|
||||||
|
default: 16_384,
|
||||||
|
upperLimit: 16_384,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope glm-4.7 uses provider-specific context and output caps', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||||
|
|
||||||
|
expect(getContextWindowForModel('glm-4.7')).toBe(202_752)
|
||||||
|
expect(getModelMaxOutputTokens('glm-4.7')).toEqual({
|
||||||
|
default: 16_384,
|
||||||
|
upperLimit: 16_384,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DashScope models clamp oversized max output overrides to the provider limit', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '100000'
|
||||||
|
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3.6-plus')).toBe(65_536)
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3.5-plus')).toBe(65_536)
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3-coder-next')).toBe(65_536)
|
||||||
|
expect(getMaxOutputTokensForModel('qwen3-max')).toBe(32_768)
|
||||||
|
expect(getMaxOutputTokensForModel('kimi-k2.5')).toBe(32_768)
|
||||||
|
expect(getMaxOutputTokensForModel('glm-5')).toBe(16_384)
|
||||||
|
})
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const CLAUDE_3_7_SONNET_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash',
|
gemini: 'gemini-2.0-flash',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
||||||
@@ -48,6 +50,8 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash',
|
gemini: 'gemini-2.0-flash',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
||||||
@@ -59,6 +63,8 @@ export const CLAUDE_3_5_HAIKU_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash-lite',
|
gemini: 'gemini-2.0-flash-lite',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
||||||
@@ -70,6 +76,8 @@ export const CLAUDE_HAIKU_4_5_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash-lite',
|
gemini: 'gemini-2.0-flash-lite',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_CONFIG = {
|
export const CLAUDE_SONNET_4_CONFIG = {
|
||||||
@@ -81,6 +89,8 @@ export const CLAUDE_SONNET_4_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash',
|
gemini: 'gemini-2.0-flash',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_5_CONFIG = {
|
export const CLAUDE_SONNET_4_5_CONFIG = {
|
||||||
@@ -92,6 +102,8 @@ export const CLAUDE_SONNET_4_5_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash',
|
gemini: 'gemini-2.0-flash',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_CONFIG = {
|
export const CLAUDE_OPUS_4_CONFIG = {
|
||||||
@@ -103,6 +115,8 @@ export const CLAUDE_OPUS_4_CONFIG = {
|
|||||||
gemini: 'gemini-2.5-pro-preview-03-25',
|
gemini: 'gemini-2.5-pro-preview-03-25',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_1_CONFIG = {
|
export const CLAUDE_OPUS_4_1_CONFIG = {
|
||||||
@@ -114,6 +128,8 @@ export const CLAUDE_OPUS_4_1_CONFIG = {
|
|||||||
gemini: 'gemini-2.5-pro-preview-03-25',
|
gemini: 'gemini-2.5-pro-preview-03-25',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_5_CONFIG = {
|
export const CLAUDE_OPUS_4_5_CONFIG = {
|
||||||
@@ -125,6 +141,8 @@ export const CLAUDE_OPUS_4_5_CONFIG = {
|
|||||||
gemini: 'gemini-2.5-pro-preview-03-25',
|
gemini: 'gemini-2.5-pro-preview-03-25',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_6_CONFIG = {
|
export const CLAUDE_OPUS_4_6_CONFIG = {
|
||||||
@@ -136,6 +154,8 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
|
|||||||
gemini: 'gemini-2.5-pro-preview-03-25',
|
gemini: 'gemini-2.5-pro-preview-03-25',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_6_CONFIG = {
|
export const CLAUDE_SONNET_4_6_CONFIG = {
|
||||||
@@ -147,6 +167,8 @@ export const CLAUDE_SONNET_4_6_CONFIG = {
|
|||||||
gemini: 'gemini-2.0-flash',
|
gemini: 'gemini-2.0-flash',
|
||||||
github: 'github:copilot',
|
github: 'github:copilot',
|
||||||
codex: 'gpt-5.4',
|
codex: 'gpt-5.4',
|
||||||
|
'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
minimax: 'MiniMax-M2.5',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Register the new config here.
|
// @[MODEL LAUNCH]: Register the new config here.
|
||||||
|
|||||||
46
src/utils/model/minimaxModels.ts
Normal file
46
src/utils/model/minimaxModels.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* MiniMax model list for the /model picker.
|
||||||
|
* Full model catalog from MiniMax API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelOption } from './modelOptions.js'
|
||||||
|
import { getAPIProvider } from './providers.js'
|
||||||
|
import { isEnvTruthy } from '../envUtils.js'
|
||||||
|
|
||||||
|
export function isMiniMaxProvider(): boolean {
|
||||||
|
if (isEnvTruthy(process.env.MINIMAX_API_KEY)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const baseUrl = process.env.OPENAI_BASE_URL ?? ''
|
||||||
|
if (baseUrl.includes('minimax')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return getAPIProvider() === 'minimax'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMiniMaxModels(): ModelOption[] {
|
||||||
|
return [
|
||||||
|
// Latest Generation Models - use correct MiniMax naming with M prefix
|
||||||
|
{ value: 'MiniMax-M2', label: 'MiniMax M2', description: 'MoE model - 131K context - Chat/Code/Reasoning' },
|
||||||
|
{ value: 'MiniMax-M2.1', label: 'MiniMax M2.1', description: 'Enhanced - 200K context - Vision' },
|
||||||
|
{ value: 'MiniMax-M2.5', label: 'MiniMax M2.5', description: 'Flagship - 256K context - Vision/Function-calling' },
|
||||||
|
{ value: 'MiniMax-Text-01', label: 'MiniMax Text 01', description: 'Text-focused - 512K context - FREE' },
|
||||||
|
{ value: 'MiniMax-Text-01-Preview', label: 'MiniMax Text 01 Preview', description: 'Preview - 256K context - FREE' },
|
||||||
|
{ value: 'MiniMax-Vision-01', label: 'MiniMax Vision 01', description: 'Vision model - 32K context' },
|
||||||
|
{ value: 'MiniMax-Vision-01-Fast', label: 'MiniMax Vision 01 Fast', description: 'Fast vision - 16K context - FREE' },
|
||||||
|
// Legacy free tier models
|
||||||
|
{ value: 'abab6.5s-chat', label: 'ABAB 6.5S Chat', description: 'Legacy free - 16K context' },
|
||||||
|
{ value: 'abab6.5-chat', label: 'ABAB 6.5 Chat', description: 'Legacy free - 32K context' },
|
||||||
|
{ value: 'abab6.5g-chat', label: 'ABAB 6.5G Chat', description: 'Generation 6.5 - 32K context' },
|
||||||
|
{ value: 'abab6-chat', label: 'ABAB 6 Chat', description: 'Legacy - 8K context' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedMiniMaxOptions: ModelOption[] | null = null
|
||||||
|
|
||||||
|
export function getCachedMiniMaxModelOptions(): ModelOption[] {
|
||||||
|
if (!cachedMiniMaxOptions) {
|
||||||
|
cachedMiniMaxOptions = getMiniMaxModels()
|
||||||
|
}
|
||||||
|
return cachedMiniMaxOptions
|
||||||
|
}
|
||||||
57
src/utils/model/model.github.test.ts
Normal file
57
src/utils/model/model.github.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { saveGlobalConfig } from '../config.js'
|
||||||
|
import { getDefaultMainLoopModelSetting, getUserSpecifiedModelSetting } from './model.js'
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
||||||
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
|
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
||||||
|
CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK,
|
||||||
|
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||||
|
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
|
||||||
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
||||||
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
|
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||||
|
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||||
|
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||||
|
delete process.env.OPENAI_MODEL
|
||||||
|
saveGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
model: ({ bad: true } as unknown) as string,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GITHUB = env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = env.CLAUDE_CODE_USE_OPENAI
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = env.CLAUDE_CODE_USE_GEMINI
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = env.CLAUDE_CODE_USE_BEDROCK
|
||||||
|
process.env.CLAUDE_CODE_USE_VERTEX = env.CLAUDE_CODE_USE_VERTEX
|
||||||
|
process.env.CLAUDE_CODE_USE_FOUNDRY = env.CLAUDE_CODE_USE_FOUNDRY
|
||||||
|
process.env.OPENAI_MODEL = env.OPENAI_MODEL
|
||||||
|
saveGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
model: undefined,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('github default model setting ignores non-string saved model', () => {
|
||||||
|
const model = getDefaultMainLoopModelSetting()
|
||||||
|
expect(typeof model).toBe('string')
|
||||||
|
expect(model).not.toBe('[object Object]')
|
||||||
|
expect(model.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('user specified model ignores non-string saved model', () => {
|
||||||
|
const model = getUserSpecifiedModelSetting()
|
||||||
|
if (model !== undefined && model !== null) {
|
||||||
|
expect(typeof model).toBe('string')
|
||||||
|
expect(model).not.toBe('[object Object]')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -33,6 +33,12 @@ export type ModelShortName = string
|
|||||||
export type ModelName = string
|
export type ModelName = string
|
||||||
export type ModelSetting = ModelName | ModelAlias | null
|
export type ModelSetting = ModelName | ModelAlias | null
|
||||||
|
|
||||||
|
function normalizeModelSetting(value: unknown): ModelName | ModelAlias | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function getSmallFastModel(): ModelName {
|
export function getSmallFastModel(): ModelName {
|
||||||
if (process.env.ANTHROPIC_SMALL_FAST_MODEL) return process.env.ANTHROPIC_SMALL_FAST_MODEL
|
if (process.env.ANTHROPIC_SMALL_FAST_MODEL) return process.env.ANTHROPIC_SMALL_FAST_MODEL
|
||||||
// For Gemini provider, use a fast model
|
// For Gemini provider, use a fast model
|
||||||
@@ -82,6 +88,7 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
|
|||||||
specifiedModel = modelOverride
|
specifiedModel = modelOverride
|
||||||
} else {
|
} else {
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
|
const setting = normalizeModelSetting(settings.model)
|
||||||
// Read the model env var that matches the active provider to prevent
|
// Read the model env var that matches the active provider to prevent
|
||||||
// cross-provider leaks (e.g. ANTHROPIC_MODEL sent to the OpenAI API).
|
// cross-provider leaks (e.g. ANTHROPIC_MODEL sent to the OpenAI API).
|
||||||
const provider = getAPIProvider()
|
const provider = getAPIProvider()
|
||||||
@@ -90,7 +97,7 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined {
|
|||||||
(provider === 'mistral' ? process.env.MISTRAL_MODEL : undefined) ||
|
(provider === 'mistral' ? process.env.MISTRAL_MODEL : undefined) ||
|
||||||
(provider === 'openai' || provider === 'gemini' || provider === 'mistral' || provider === 'github' ? process.env.OPENAI_MODEL : undefined) ||
|
(provider === 'openai' || provider === 'gemini' || provider === 'mistral' || provider === 'github' ? process.env.OPENAI_MODEL : undefined) ||
|
||||||
(provider === 'firstParty' ? process.env.ANTHROPIC_MODEL : undefined) ||
|
(provider === 'firstParty' ? process.env.ANTHROPIC_MODEL : undefined) ||
|
||||||
settings.model ||
|
setting ||
|
||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +271,11 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
|
|||||||
// GitHub Copilot provider: check settings.model first, then env, then default
|
// GitHub Copilot provider: check settings.model first, then env, then default
|
||||||
if (getAPIProvider() === 'github') {
|
if (getAPIProvider() === 'github') {
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
return settings.model || process.env.OPENAI_MODEL || 'github:copilot'
|
return (
|
||||||
|
normalizeModelSetting(settings.model) ||
|
||||||
|
normalizeModelSetting(process.env.OPENAI_MODEL) ||
|
||||||
|
'github:copilot'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// Gemini provider: always use the configured Gemini model
|
// Gemini provider: always use the configured Gemini model
|
||||||
if (getAPIProvider() === 'gemini') {
|
if (getAPIProvider() === 'gemini') {
|
||||||
@@ -595,7 +606,10 @@ export function getPublicModelName(model: ModelName): string {
|
|||||||
export function parseUserSpecifiedModel(
|
export function parseUserSpecifiedModel(
|
||||||
modelInput: ModelName | ModelAlias,
|
modelInput: ModelName | ModelAlias,
|
||||||
): ModelName {
|
): ModelName {
|
||||||
const modelInputTrimmed = modelInput.trim()
|
const modelInputTrimmed = normalizeModelSetting(modelInput)
|
||||||
|
if (!modelInputTrimmed) {
|
||||||
|
return getDefaultSonnetModel()
|
||||||
|
}
|
||||||
const normalizedModel = modelInputTrimmed.toLowerCase()
|
const normalizedModel = modelInputTrimmed.toLowerCase()
|
||||||
|
|
||||||
const has1mTag = has1mContext(normalizedModel)
|
const has1mTag = has1mContext(normalizedModel)
|
||||||
|
|||||||
@@ -33,8 +33,14 @@ import {
|
|||||||
} from './model.js'
|
} from './model.js'
|
||||||
import { has1mContext } from '../context.js'
|
import { has1mContext } from '../context.js'
|
||||||
import { getGlobalConfig } from '../config.js'
|
import { getGlobalConfig } from '../config.js'
|
||||||
import { getActiveOpenAIModelOptionsCache } from '../providerProfiles.js'
|
import {
|
||||||
|
getActiveOpenAIModelOptionsCache,
|
||||||
|
getActiveProviderProfile,
|
||||||
|
getProfileModelOptions,
|
||||||
|
} from '../providerProfiles.js'
|
||||||
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
||||||
|
import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js'
|
||||||
|
import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js'
|
||||||
import { getAntModels } from './antModels.js'
|
import { getAntModels } from './antModels.js'
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
|
// @[MODEL LAUNCH]: Update all the available and default model option strings below.
|
||||||
@@ -390,6 +396,26 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
return [defaultOption]
|
return [defaultOption]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When using NVIDIA NIM, show models from the NVIDIA catalog
|
||||||
|
if (isNvidiaNimProvider()) {
|
||||||
|
const defaultOption = getDefaultOptionForUser(fastMode)
|
||||||
|
const nvidiaModels = getCachedNvidiaNimModelOptions()
|
||||||
|
if (nvidiaModels.length > 0) {
|
||||||
|
return [defaultOption, ...nvidiaModels]
|
||||||
|
}
|
||||||
|
return [defaultOption]
|
||||||
|
}
|
||||||
|
|
||||||
|
// When using MiniMax, show models from the MiniMax catalog
|
||||||
|
if (isMiniMaxProvider()) {
|
||||||
|
const defaultOption = getDefaultOptionForUser(fastMode)
|
||||||
|
const minimaxModels = getCachedMiniMaxModelOptions()
|
||||||
|
if (minimaxModels.length > 0) {
|
||||||
|
return [defaultOption, ...minimaxModels]
|
||||||
|
}
|
||||||
|
return [defaultOption]
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
// Build options from antModels config
|
// Build options from antModels config
|
||||||
const antModelOptions: ModelOption[] = getAntModels().map(m => ({
|
const antModelOptions: ModelOption[] = getAntModels().map(m => ({
|
||||||
@@ -454,6 +480,20 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a provider profile's env is applied, collect its models so they
|
||||||
|
// can be appended to the standard picker options below.
|
||||||
|
// We check PROFILE_ENV_APPLIED to avoid the ?? profiles[0] fallback in
|
||||||
|
// getActiveProviderProfile which would affect users with inactive profiles.
|
||||||
|
const profileEnvApplied = process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
|
||||||
|
const profileModelOptions: ModelOption[] = []
|
||||||
|
if (profileEnvApplied) {
|
||||||
|
const activeProfile = getActiveProviderProfile()
|
||||||
|
if (activeProfile) {
|
||||||
|
const models = getProfileModelOptions(activeProfile)
|
||||||
|
profileModelOptions.push(...models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
// PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.6 + Opus 1M + Haiku
|
||||||
if (getAPIProvider() === 'firstParty') {
|
if (getAPIProvider() === 'firstParty') {
|
||||||
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
const payg1POptions = [getDefaultOptionForUser(fastMode)]
|
||||||
@@ -469,6 +509,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
payg1POptions.push(getHaiku45Option())
|
payg1POptions.push(getHaiku45Option())
|
||||||
|
payg1POptions.push(...profileModelOptions)
|
||||||
return payg1POptions
|
return payg1POptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,6 +549,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
|
|||||||
} else {
|
} else {
|
||||||
payg3pOptions.push(getHaikuOption())
|
payg3pOptions.push(getHaikuOption())
|
||||||
}
|
}
|
||||||
|
payg3pOptions.push(...profileModelOptions)
|
||||||
return payg3pOptions
|
return payg3pOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
src/utils/model/nvidiaNimModels.ts
Normal file
161
src/utils/model/nvidiaNimModels.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* NVIDIA NIM model list for the /model picker.
|
||||||
|
* Filtered to chat/instruct models only - embedding, reward, safety, vision, etc. excluded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ModelOption } from './modelOptions.js'
|
||||||
|
import { getAPIProvider } from './providers.js'
|
||||||
|
import { isEnvTruthy } from '../envUtils.js'
|
||||||
|
|
||||||
|
export function isNvidiaNimProvider(): boolean {
|
||||||
|
// Check if explicitly set via NVIDIA_NIM or via provider flag
|
||||||
|
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Also check if using NVIDIA NIM endpoint
|
||||||
|
const baseUrl = process.env.OPENAI_BASE_URL ?? ''
|
||||||
|
if (baseUrl.includes('nvidia') || baseUrl.includes('integrate.api.nvidia')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return getAPIProvider() === 'nvidia-nim'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNvidiaNimModels(): ModelOption[] {
|
||||||
|
return [
|
||||||
|
// AGENTIC REASONING MODELS
|
||||||
|
{ value: 'nvidia/cosmos-reason2-8b', label: 'Cosmos Reason 2 8B', description: 'Reasoning' },
|
||||||
|
{ value: 'microsoft/phi-4-mini-flash-reasoning', label: 'Phi 4 Mini Flash Reasoning', description: 'Reasoning' },
|
||||||
|
{ value: 'qwen/qwen3-next-80b-a3b-thinking', label: 'Qwen 3 Next 80B Thinking', description: 'Reasoning' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-r1-distill-qwen-32b', label: 'DeepSeek R1 Qwen 32B', description: 'Reasoning' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-r1-distill-qwen-14b', label: 'DeepSeek R1 Qwen 14B', description: 'Reasoning' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-r1-distill-qwen-7b', label: 'DeepSeek R1 Qwen 7B', description: 'Reasoning' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-r1-distill-llama-8b', label: 'DeepSeek R1 Llama 8B', description: 'Reasoning' },
|
||||||
|
{ value: 'qwen/qwq-32b', label: 'QwQ 32B Reasoning', description: 'Reasoning' },
|
||||||
|
// CODE MODELS
|
||||||
|
{ value: 'meta/codellama-70b', label: 'CodeLlama 70B', description: 'Code' },
|
||||||
|
{ value: 'bigcode/starcoder2-15b', label: 'StarCoder2 15B', description: 'Code' },
|
||||||
|
{ value: 'bigcode/starcoder2-7b', label: 'StarCoder2 7B', description: 'Code' },
|
||||||
|
{ value: 'mistralai/codestral-22b-instruct-v0.1', label: 'Codestral 22B', description: 'Code' },
|
||||||
|
{ value: 'mistralai/mamba-codestral-7b-v0.1', label: 'Mamba Codestral 7B', description: 'Code' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-coder-6.7b-instruct', label: 'DeepSeek Coder 6.7B', description: 'Code' },
|
||||||
|
{ value: 'google/codegemma-7b', label: 'CodeGemma 7B', description: 'Code' },
|
||||||
|
{ value: 'google/codegemma-1.1-7b', label: 'CodeGemma 1.1 7B', description: 'Code' },
|
||||||
|
{ value: 'qwen/qwen2.5-coder-32b-instruct', label: 'Qwen 2.5 Coder 32B', description: 'Code' },
|
||||||
|
{ value: 'qwen/qwen2.5-coder-7b-instruct', label: 'Qwen 2.5 Coder 7B', description: 'Code' },
|
||||||
|
{ value: 'qwen/qwen3-coder-480b-a35b-instruct', label: 'Qwen 3 Coder 480B', description: 'Code' },
|
||||||
|
{ value: 'ibm/granite-34b-code-instruct', label: 'Granite 34B Code', description: 'Code' },
|
||||||
|
{ value: 'ibm/granite-8b-code-instruct', label: 'Granite 8B Code', description: 'Code' },
|
||||||
|
// NEMOTRON MODELS - NVIDIA Flagship
|
||||||
|
{ value: 'nvidia/llama-3.1-nemotron-70b-instruct', label: 'Nemotron 70B Instruct', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.1-nemotron-51b-instruct', label: 'Nemotron 51B Instruct', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.1-nemotron-ultra-253b-v1', label: 'Nemotron Ultra 253B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.3-nemotron-super-49b-v1', label: 'Nemotron Super 49B v1', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.3-nemotron-super-49b-v1.5', label: 'Nemotron Super 49B v1.5', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/nemotron-4-340b-instruct', label: 'Nemotron 4 340B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/nemotron-3-super-120b-a12b', label: 'Nemotron 3 Super 120B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/nemotron-3-nano-30b-a3b', label: 'Nemotron 3 Nano 30B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/nemotron-mini-4b-instruct', label: 'Nemotron Mini 4B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.1-nemotron-nano-8b-v1', label: 'Nemotron Nano 8B', description: 'NVIDIA Flagship' },
|
||||||
|
{ value: 'nvidia/llama-3.1-nemotron-nano-4b-v1.1', label: 'Nemotron Nano 4B v1.1', description: 'NVIDIA Flagship' },
|
||||||
|
// CHATQA MODELS
|
||||||
|
{ value: 'nvidia/llama3-chatqa-1.5-70b', label: 'Llama3 ChatQA 1.5 70B', description: 'Chat' },
|
||||||
|
{ value: 'nvidia/llama3-chatqa-1.5-8b', label: 'Llama3 ChatQA 1.5 8B', description: 'Chat' },
|
||||||
|
// META LLAMA MODELS
|
||||||
|
{ value: 'meta/llama-3.1-405b-instruct', label: 'Llama 3.1 405B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.1-70b-instruct', label: 'Llama 3.1 70B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.1-8b-instruct', label: 'Llama 3.1 8B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.2-90b-vision-instruct', label: 'Llama 3.2 90B Vision', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.2-11b-vision-instruct', label: 'Llama 3.2 11B Vision', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.2-3b-instruct', label: 'Llama 3.2 3B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.2-1b-instruct', label: 'Llama 3.2 1B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-3.3-70b-instruct', label: 'Llama 3.3 70B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-4-maverick-17b-128e-instruct', label: 'Llama 4 Maverick 17B', description: 'Meta Llama' },
|
||||||
|
{ value: 'meta/llama-4-scout-17b-16e-instruct', label: 'Llama 4 Scout 17B', description: 'Meta Llama' },
|
||||||
|
// GOOGLE GEMMA MODELS (text only - no vision)
|
||||||
|
{ value: 'google/gemma-4-31b-it', label: 'Gemma 4 31B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3-27b-it', label: 'Gemma 3 27B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3-12b-it', label: 'Gemma 3 12B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3-4b-it', label: 'Gemma 3 4B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3-1b-it', label: 'Gemma 3 1B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3n-e4b-it', label: 'Gemma 3N E4B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-3n-e2b-it', label: 'Gemma 3N E2B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-2-27b-it', label: 'Gemma 2 27B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-2-9b-it', label: 'Gemma 2 9B', description: 'Google Gemma' },
|
||||||
|
{ value: 'google/gemma-2-2b-it', label: 'Gemma 2 2B', description: 'Google Gemma' },
|
||||||
|
// MISTRAL MODELS
|
||||||
|
{ value: 'mistralai/mistral-large-3-675b-instruct-2512', label: 'Mistral Large 3 675B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-large-2-instruct', label: 'Mistral Large 2', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-large', label: 'Mistral Large', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-medium-3-instruct', label: 'Mistral Medium 3', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-small-4-119b-2603', label: 'Mistral Small 4 119B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-small-3.1-24b-instruct-2503', label: 'Mistral Small 3.1 24B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-small-24b-instruct', label: 'Mistral Small 24B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-7b-instruct-v0.3', label: 'Mistral 7B v0.3', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-7b-instruct-v0.2', label: 'Mistral 7B v0.2', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mixtral-8x22b-instruct-v0.1', label: 'Mixtral 8x22B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mixtral-8x22b-instruct-v0.1', label: 'Mixtral 8x22B Instruct', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mixtral-8x7b-instruct-v0.1', label: 'Mixtral 8x7B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mistral-nemotron', label: 'Mistral Nemotron', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/mathstral-7b-v0.1', label: 'Mathstral 7B', description: 'Math' },
|
||||||
|
{ value: 'mistralai/ministral-14b-instruct-2512', label: 'Ministral 14B', description: 'Mistral' },
|
||||||
|
{ value: 'mistralai/devstral-2-123b-instruct-2512', label: 'Devstral 2 123B', description: 'Code' },
|
||||||
|
{ value: 'mistralai/magistral-small-2506', label: 'Magistral Small', description: 'Mistral' },
|
||||||
|
// MICROSOFT PHI MODELS (text only - no vision)
|
||||||
|
{ value: 'microsoft/phi-4-multimodal-instruct', label: 'Phi 4 Multimodal', description: 'Multimodal' },
|
||||||
|
{ value: 'microsoft/phi-4-mini-instruct', label: 'Phi 4 Mini', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3.5-mini-instruct', label: 'Phi 3.5 Mini', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-small-128k-instruct', label: 'Phi 3 Small 128K', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-small-8k-instruct', label: 'Phi 3 Small 8K', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-medium-128k-instruct', label: 'Phi 3 Medium 128K', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-medium-4k-instruct', label: 'Phi 3 Medium 4K', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-mini-128k-instruct', label: 'Phi 3 Mini 128K', description: 'Phi' },
|
||||||
|
{ value: 'microsoft/phi-3-mini-4k-instruct', label: 'Phi 3 Mini 4K', description: 'Phi' },
|
||||||
|
// QWEN MODELS
|
||||||
|
{ value: 'qwen/qwen3.5-397b-a17b', label: 'Qwen 3.5 397B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen3.5-122b-a10b', label: 'Qwen 3.5 122B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen3-next-80b-a3b-instruct', label: 'Qwen 3 Next 80B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen2.5-7b-instruct', label: 'Qwen 2.5 7B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen2-7b-instruct', label: 'Qwen 2 7B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen3-32b', label: 'Qwen 3 32B', description: 'Qwen' },
|
||||||
|
{ value: 'qwen/qwen3-8b', label: 'Qwen 3 8B', description: 'Qwen' },
|
||||||
|
// DEEPSEEK MODELS
|
||||||
|
{ value: 'deepseek-ai/deepseek-r1', label: 'DeepSeek R1', description: 'DeepSeek' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-v3', label: 'DeepSeek V3', description: 'DeepSeek' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-v3.2', label: 'DeepSeek V3.2', description: 'DeepSeek' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-v3.1-terminus', label: 'DeepSeek V3.1 Terminus', description: 'DeepSeek' },
|
||||||
|
{ value: 'deepseek-ai/deepseek-v3.1', label: 'DeepSeek V3.1', description: 'DeepSeek' },
|
||||||
|
// IBM GRANITE MODELS
|
||||||
|
{ value: 'ibm/granite-3.3-8b-instruct', label: 'Granite 3.3 8B', description: 'IBM Granite' },
|
||||||
|
{ value: 'ibm/granite-3.0-8b-instruct', label: 'Granite 3.0 8B', description: 'IBM Granite' },
|
||||||
|
{ value: 'ibm/granite-3.0-3b-a800m-instruct', label: 'Granite 3.0 3B', description: 'IBM Granite' },
|
||||||
|
// OTHER MODELS
|
||||||
|
{ value: 'databricks/dbrx-instruct', label: 'DBRX Instruct', description: 'Other' },
|
||||||
|
{ value: '01-ai/yi-large', label: 'Yi Large', description: 'Other' },
|
||||||
|
{ value: 'ai21labs/jamba-1.5-large-instruct', label: 'Jamba 1.5 Large', description: 'Other' },
|
||||||
|
{ value: 'ai21labs/jamba-1.5-mini-instruct', label: 'Jamba 1.5 Mini', description: 'Other' },
|
||||||
|
{ value: 'writer/palmyra-creative-122b', label: 'Palmyra Creative 122B', description: 'Other' },
|
||||||
|
{ value: 'writer/palmyra-fin-70b-32k', label: 'Palmyra Fin 70B 32K', description: 'Other' },
|
||||||
|
{ value: 'writer/palmyra-med-70b', label: 'Palmyra Med 70B', description: 'Other' },
|
||||||
|
{ value: 'writer/palmyra-med-70b-32k', label: 'Palmyra Med 70B 32K', description: 'Other' },
|
||||||
|
// Z-AI GLM MODELS
|
||||||
|
{ value: 'z-ai/glm5', label: 'GLM-5', description: 'Z-AI' },
|
||||||
|
{ value: 'z-ai/glm4.7', label: 'GLM-4.7', description: 'Z-AI' },
|
||||||
|
// MINIMAX MODELS
|
||||||
|
{ value: 'minimaxai/minimax-m2.5', label: 'MiniMax M2.5', description: 'MiniMax' },
|
||||||
|
// MOONSHOT KIMI MODELS
|
||||||
|
{ value: 'moonshotai/kimi-k2.5', label: 'Kimi K2.5', description: 'Moonshot' },
|
||||||
|
{ value: 'moonshotai/kimi-k2-instruct', label: 'Kimi K2 Instruct', description: 'Moonshot' },
|
||||||
|
{ value: 'moonshotai/kimi-k2-thinking', label: 'Kimi K2 Thinking', description: 'Moonshot' },
|
||||||
|
{ value: 'moonshotai/kimi-k2.5-thinking', label: 'Kimi K2.5 Thinking', description: 'Moonshot' },
|
||||||
|
{ value: 'moonshotai/kimi-k2-instruct-0905', label: 'Kimi K2 Instruct 0905', description: 'Moonshot' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedNvidiaNimOptions: ModelOption[] | null = null
|
||||||
|
|
||||||
|
export function getCachedNvidiaNimModelOptions(): ModelOption[] {
|
||||||
|
if (!cachedNvidiaNimOptions) {
|
||||||
|
cachedNvidiaNimOptions = getNvidiaNimModels()
|
||||||
|
}
|
||||||
|
return cachedNvidiaNimOptions
|
||||||
|
}
|
||||||
@@ -104,6 +104,57 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
'devstral-latest': 256_000,
|
'devstral-latest': 256_000,
|
||||||
'ministral-3b-latest': 256_000,
|
'ministral-3b-latest': 256_000,
|
||||||
|
|
||||||
|
// NVIDIA NIM - popular models
|
||||||
|
'nvidia/llama-3.1-nemotron-70b-instruct': 128_000,
|
||||||
|
'nvidia/llama-3.1-nemotron-ultra-253b-v1': 128_000,
|
||||||
|
'nvidia/nemotron-mini-4b-instruct': 32_768,
|
||||||
|
'meta/llama-3.1-405b-instruct': 128_000,
|
||||||
|
'meta/llama-3.1-70b-instruct': 128_000,
|
||||||
|
'meta/llama-3.1-8b-instruct': 128_000,
|
||||||
|
'meta/llama-3.2-90b-instruct': 128_000,
|
||||||
|
'meta/llama-3.2-1b-instruct': 128_000,
|
||||||
|
'meta/llama-3.2-3b-instruct': 128_000,
|
||||||
|
'meta/llama-3.3-70b-instruct': 128_000,
|
||||||
|
// Google Gemma via NVIDIA NIM
|
||||||
|
'google/gemma-2-27b-it': 8_192,
|
||||||
|
'google/gemma-2-9b-it': 8_192,
|
||||||
|
'google/gemma-3-27b-it': 131_072,
|
||||||
|
'google/gemma-3-12b-it': 131_072,
|
||||||
|
'google/gemma-3-4b-it': 131_072,
|
||||||
|
// DeepSeek via NVIDIA NIM
|
||||||
|
'deepseek-ai/deepseek-r1': 128_000,
|
||||||
|
'deepseek-ai/deepseek-v3': 128_000,
|
||||||
|
'deepseek-ai/deepseek-v3.2': 128_000,
|
||||||
|
// Qwen via NVIDIA NIM
|
||||||
|
'qwen/qwen3-32b': 128_000,
|
||||||
|
'qwen/qwen3-8b': 128_000,
|
||||||
|
'qwen/qwen2.5-7b-instruct': 32_768,
|
||||||
|
// Mistral via NVIDIA NIM
|
||||||
|
'mistralai/mistral-large-3-675b-instruct-2512': 256_000,
|
||||||
|
'mistralai/mistral-large-2-instruct': 256_000,
|
||||||
|
'mistralai/mistral-small-3.1-24b-instruct-2503': 32_768,
|
||||||
|
'mistralai/mixtral-8x7b-instruct-v0.1': 32_768,
|
||||||
|
// Microsoft Phi via NVIDIA NIM
|
||||||
|
'microsoft/phi-4-mini-instruct': 16_384,
|
||||||
|
'microsoft/phi-3.5-mini-instruct': 16_384,
|
||||||
|
'microsoft/phi-3-mini-128k-instruct': 128_000,
|
||||||
|
// IBM Granite via NVIDIA NIM
|
||||||
|
'ibm/granite-3.3-8b-instruct': 8_192,
|
||||||
|
'ibm/granite-8b-code-instruct': 8_192,
|
||||||
|
// GLM models via NVIDIA NIM
|
||||||
|
'z-ai/glm5': 200_000,
|
||||||
|
'z-ai/glm4.7': 128_000,
|
||||||
|
// Kimi models via NVIDIA NIM
|
||||||
|
'moonshotai/kimi-k2.5': 200_000,
|
||||||
|
'moonshotai/kimi-k2-instruct': 128_000,
|
||||||
|
// DBRX via NVIDIA NIM
|
||||||
|
'databricks/dbrx-instruct': 131_072,
|
||||||
|
// Jamba via NVIDIA NIM
|
||||||
|
'ai21labs/jamba-1.5-large-instruct': 256_000,
|
||||||
|
'ai21labs/jamba-1.5-mini-instruct': 256_000,
|
||||||
|
// Yi via NVIDIA NIM
|
||||||
|
'01-ai/yi-large': 32_768,
|
||||||
|
|
||||||
// MiniMax (all M2.x variants share 204,800 context, 131,072 max output)
|
// MiniMax (all M2.x variants share 204,800 context, 131,072 max output)
|
||||||
'MiniMax-M2.7': 204_800,
|
'MiniMax-M2.7': 204_800,
|
||||||
'MiniMax-M2.7-highspeed': 204_800,
|
'MiniMax-M2.7-highspeed': 204_800,
|
||||||
@@ -118,6 +169,13 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
'minimax-m2.1': 204_800,
|
'minimax-m2.1': 204_800,
|
||||||
'minimax-m2.1-highspeed': 204_800,
|
'minimax-m2.1-highspeed': 204_800,
|
||||||
|
|
||||||
|
// MiniMax new models
|
||||||
|
'MiniMax-Text-01': 524_288,
|
||||||
|
'MiniMax-Text-01-Preview': 262_144,
|
||||||
|
'MiniMax-Vision-01': 32_768,
|
||||||
|
'MiniMax-Vision-01-Fast': 16_384,
|
||||||
|
'MiniMax-M2': 204_800,
|
||||||
|
|
||||||
// Google (via OpenRouter)
|
// Google (via OpenRouter)
|
||||||
'google/gemini-2.0-flash':1_048_576,
|
'google/gemini-2.0-flash':1_048_576,
|
||||||
'google/gemini-2.5-pro': 1_048_576,
|
'google/gemini-2.5-pro': 1_048_576,
|
||||||
@@ -144,6 +202,21 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
|
|||||||
'llama3.2:1b': 128_000,
|
'llama3.2:1b': 128_000,
|
||||||
'qwen3:8b': 128_000,
|
'qwen3:8b': 128_000,
|
||||||
'codestral': 32_768,
|
'codestral': 32_768,
|
||||||
|
|
||||||
|
// Alibaba DashScope (Coding Plan)
|
||||||
|
// Model context windows from DashScope API /models endpoint (April 2026).
|
||||||
|
// Values sourced from: qwen3.5-plus/qwen3-coder-plus (1M), qwen3-coder-next/max (256K),
|
||||||
|
// kimi-k2.5 (256K), glm-5/glm-4.7 (198K).
|
||||||
|
// Max output tokens: Qwen variants (64K/32K), GLM (16K).
|
||||||
|
'qwen3.6-plus': 1_000_000,
|
||||||
|
'qwen3.5-plus': 1_000_000,
|
||||||
|
'qwen3-coder-plus': 1_000_000,
|
||||||
|
'qwen3-coder-next': 262_144,
|
||||||
|
'qwen3-max': 262_144,
|
||||||
|
'qwen3-max-2026-01-23': 262_144,
|
||||||
|
'kimi-k2.5': 262_144,
|
||||||
|
'glm-5': 202_752,
|
||||||
|
'glm-4.7': 202_752,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,6 +319,12 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|||||||
'minimax-m2.5-highspeed': 131_072,
|
'minimax-m2.5-highspeed': 131_072,
|
||||||
'minimax-m2.1': 131_072,
|
'minimax-m2.1': 131_072,
|
||||||
'minimax-m2.1-highspeed': 131_072,
|
'minimax-m2.1-highspeed': 131_072,
|
||||||
|
// New MiniMax models
|
||||||
|
'MiniMax-M2': 131_072,
|
||||||
|
'MiniMax-Text-01': 65_536,
|
||||||
|
'MiniMax-Text-01-Preview': 65_536,
|
||||||
|
'MiniMax-Vision-01': 16_384,
|
||||||
|
'MiniMax-Vision-01-Fast': 16_384,
|
||||||
|
|
||||||
// Google (via OpenRouter)
|
// Google (via OpenRouter)
|
||||||
'google/gemini-2.0-flash': 8_192,
|
'google/gemini-2.0-flash': 8_192,
|
||||||
@@ -271,6 +350,43 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|||||||
'llama3.2:1b': 4_096,
|
'llama3.2:1b': 4_096,
|
||||||
'qwen3:8b': 8_192,
|
'qwen3:8b': 8_192,
|
||||||
'codestral': 8_192,
|
'codestral': 8_192,
|
||||||
|
|
||||||
|
// NVIDIA NIM models
|
||||||
|
'nvidia/llama-3.1-nemotron-70b-instruct': 32_768,
|
||||||
|
'nvidia/nemotron-mini-4b-instruct': 8_192,
|
||||||
|
'meta/llama-3.1-405b-instruct': 32_768,
|
||||||
|
'meta/llama-3.1-70b-instruct': 32_768,
|
||||||
|
'meta/llama-3.2-90b-instruct': 32_768,
|
||||||
|
'meta/llama-3.3-70b-instruct': 32_768,
|
||||||
|
'google/gemma-2-27b-it': 4_096,
|
||||||
|
'google/gemma-3-27b-it': 16_384,
|
||||||
|
'google/gemma-3-12b-it': 16_384,
|
||||||
|
'deepseek-ai/deepseek-r1': 32_768,
|
||||||
|
'deepseek-ai/deepseek-v3': 32_768,
|
||||||
|
'deepseek-ai/deepseek-v3.2': 32_768,
|
||||||
|
'qwen/qwen3-32b': 32_768,
|
||||||
|
'qwen/qwen2.5-7b-instruct': 8_192,
|
||||||
|
'mistralai/mistral-large-3-675b-instruct-2512': 32_768,
|
||||||
|
'mistralai/mixtral-8x7b-instruct-v0.1': 8_192,
|
||||||
|
'microsoft/phi-4-mini-instruct': 4_096,
|
||||||
|
'microsoft/phi-3.5-mini-instruct': 4_096,
|
||||||
|
'ibm/granite-3.3-8b-instruct': 4_096,
|
||||||
|
'z-ai/glm5': 32_768,
|
||||||
|
'moonshotai/kimi-k2.5': 32_768,
|
||||||
|
'databricks/dbrx-instruct': 32_768,
|
||||||
|
'ai21labs/jamba-1.5-large-instruct': 32_768,
|
||||||
|
'01-ai/yi-large': 8_192,
|
||||||
|
|
||||||
|
// Alibaba DashScope (Coding Plan)
|
||||||
|
'qwen3.6-plus': 65_536,
|
||||||
|
'qwen3.5-plus': 65_536,
|
||||||
|
'qwen3-coder-plus': 65_536,
|
||||||
|
'qwen3-coder-next': 65_536,
|
||||||
|
'qwen3-max': 32_768,
|
||||||
|
'qwen3-max-2026-01-23': 32_768,
|
||||||
|
'kimi-k2.5': 32_768,
|
||||||
|
'glm-5': 16_384,
|
||||||
|
'glm-4.7': 16_384,
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookupByModel<T>(table: Record<string, T>, model: string): T | undefined {
|
function lookupByModel<T>(table: Record<string, T>, model: string): T | undefined {
|
||||||
|
|||||||
@@ -11,9 +11,17 @@ export type APIProvider =
|
|||||||
| 'gemini'
|
| 'gemini'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'codex'
|
| 'codex'
|
||||||
|
| 'nvidia-nim'
|
||||||
|
| 'minimax'
|
||||||
| 'mistral'
|
| 'mistral'
|
||||||
|
|
||||||
export function getAPIProvider(): APIProvider {
|
export function getAPIProvider(): APIProvider {
|
||||||
|
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
||||||
|
return 'nvidia-nim'
|
||||||
|
}
|
||||||
|
if (isEnvTruthy(process.env.MINIMAX_API_KEY)) {
|
||||||
|
return 'minimax'
|
||||||
|
}
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
? 'gemini'
|
? 'gemini'
|
||||||
:
|
:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@anthropic-ai/sdk'
|
} from '@anthropic-ai/sdk'
|
||||||
import { getModelStrings } from './modelStrings.js'
|
import { getModelStrings } from './modelStrings.js'
|
||||||
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
import { getCachedOllamaModelOptions, isOllamaProvider } from './ollamaModels.js'
|
||||||
|
import { getCachedNvidiaNimModelOptions, isNvidiaNimProvider } from './nvidiaNimModels.js'
|
||||||
|
import { getCachedMiniMaxModelOptions, isMiniMaxProvider } from './minimaxModels.js'
|
||||||
|
|
||||||
// Cache valid models to avoid repeated API calls
|
// Cache valid models to avoid repeated API calls
|
||||||
const validModelCache = new Map<string, boolean>()
|
const validModelCache = new Map<string, boolean>()
|
||||||
@@ -47,6 +49,40 @@ export async function validateModel(
|
|||||||
// If cache is empty, fall through to API validation
|
// If cache is empty, fall through to API validation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For NVIDIA NIM provider, validate against cached model list
|
||||||
|
if (isNvidiaNimProvider()) {
|
||||||
|
const nvidiaModels = getCachedNvidiaNimModelOptions()
|
||||||
|
const found = nvidiaModels.some(m => m.value === normalizedModel)
|
||||||
|
if (found) {
|
||||||
|
validModelCache.set(normalizedModel, true)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
if (nvidiaModels.length > 0) {
|
||||||
|
const MAX_SHOWN = 5
|
||||||
|
const names = nvidiaModels.map(m => m.value)
|
||||||
|
const shown = names.slice(0, MAX_SHOWN).join(', ')
|
||||||
|
const suffix = names.length > MAX_SHOWN ? ` and ${names.length - MAX_SHOWN} more` : ''
|
||||||
|
return { valid: false, error: `Model '${normalizedModel}' not found in NVIDIA NIM catalog. Available: ${shown}${suffix}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For MiniMax provider, validate against cached model list
|
||||||
|
if (isMiniMaxProvider()) {
|
||||||
|
const minimaxModels = getCachedMiniMaxModelOptions()
|
||||||
|
const found = minimaxModels.some(m => m.value === normalizedModel)
|
||||||
|
if (found) {
|
||||||
|
validModelCache.set(normalizedModel, true)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
if (minimaxModels.length > 0) {
|
||||||
|
const MAX_SHOWN = 5
|
||||||
|
const names = minimaxModels.map(m => m.value)
|
||||||
|
const shown = names.slice(0, MAX_SHOWN).join(', ')
|
||||||
|
const suffix = names.length > MAX_SHOWN ? ` and ${names.length - MAX_SHOWN} more` : ''
|
||||||
|
return { valid: false, error: `Model '${normalizedModel}' not found in MiniMax catalog. Available: ${shown}${suffix}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check against availableModels allowlist before any API call
|
// Check against availableModels allowlist before any API call
|
||||||
if (!isModelAllowed(normalizedModel)) {
|
if (!isModelAllowed(normalizedModel)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ describe('OpenClaude paths', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('local installer uses openclaude wrapper path', async () => {
|
test('local installer uses openclaude wrapper path', async () => {
|
||||||
delete process.env.CLAUDE_CONFIG_DIR
|
// Force .openclaude config home so the test doesn't fall back to
|
||||||
|
// ~/.claude when ~/.openclaude doesn't exist on this machine.
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = join(homedir(), '.openclaude')
|
||||||
const { getLocalClaudePath } = await importFreshLocalInstaller()
|
const { getLocalClaudePath } = await importFreshLocalInstaller()
|
||||||
|
|
||||||
expect(getLocalClaudePath()).toBe(
|
expect(getLocalClaudePath()).toBe(
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ export function getLocalOpenAICompatibleProviderLabel(baseUrl?: string): string
|
|||||||
) {
|
) {
|
||||||
return 'text-generation-webui'
|
return 'text-generation-webui'
|
||||||
}
|
}
|
||||||
|
// Check for NVIDIA NIM
|
||||||
|
if (host.includes('nvidia') || haystack.includes('nvidia') || host.includes('integrate.api.nvidia')) {
|
||||||
|
return 'NVIDIA NIM'
|
||||||
|
}
|
||||||
|
// Check for MiniMax (both api.minimax.io and api.minimax.chat)
|
||||||
|
if (host.includes('minimax') || haystack.includes('minimax')) {
|
||||||
|
return 'MiniMax'
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to the generic label when the base URL is malformed.
|
// Fall back to the generic label when the base URL is malformed.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const VALID_PROVIDERS = [
|
|||||||
'bedrock',
|
'bedrock',
|
||||||
'vertex',
|
'vertex',
|
||||||
'ollama',
|
'ollama',
|
||||||
|
'nvidia-nim',
|
||||||
|
'minimax',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type ProviderFlagName = (typeof VALID_PROVIDERS)[number]
|
export type ProviderFlagName = (typeof VALID_PROVIDERS)[number]
|
||||||
@@ -131,6 +133,21 @@ export function applyProviderFlag(
|
|||||||
}
|
}
|
||||||
if (model) process.env.OPENAI_MODEL = model
|
if (model) process.env.OPENAI_MODEL = model
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'nvidia-nim':
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL ??= 'https://integrate.api.nvidia.com/v1'
|
||||||
|
process.env.NVIDIA_NIM = '1'
|
||||||
|
process.env.OPENAI_MODEL ??= 'nvidia/llama-3.1-nemotron-70b-instruct'
|
||||||
|
if (model) process.env.OPENAI_MODEL = model
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'minimax':
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL ??= 'https://api.minimax.io/v1'
|
||||||
|
process.env.OPENAI_MODEL ??= 'MiniMax-M2.5'
|
||||||
|
if (model) process.env.OPENAI_MODEL = model
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
108
src/utils/providerModels.test.ts
Normal file
108
src/utils/providerModels.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPrimaryModel,
|
||||||
|
hasMultipleModels,
|
||||||
|
parseModelList,
|
||||||
|
} from './providerModels.ts'
|
||||||
|
|
||||||
|
// ── parseModelList ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseModelList', () => {
|
||||||
|
test('splits comma-separated models', () => {
|
||||||
|
expect(parseModelList('glm-4.7, glm-4.7-flash')).toEqual([
|
||||||
|
'glm-4.7',
|
||||||
|
'glm-4.7-flash',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns single model in an array', () => {
|
||||||
|
expect(parseModelList('llama3.1:8b')).toEqual(['llama3.1:8b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trims whitespace around each model', () => {
|
||||||
|
expect(parseModelList(' gpt-4o , gpt-4o-mini , o3-mini ')).toEqual([
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'o3-mini',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters out empty entries from trailing commas', () => {
|
||||||
|
expect(parseModelList('gpt-4o,,gpt-4o-mini,')).toEqual([
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for empty string', () => {
|
||||||
|
expect(parseModelList('')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for whitespace-only string', () => {
|
||||||
|
expect(parseModelList(' ')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for comma-only string', () => {
|
||||||
|
expect(parseModelList(',,,')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles models with colons', () => {
|
||||||
|
expect(parseModelList('qwen2.5-coder:7b, llama3.1:8b')).toEqual([
|
||||||
|
'qwen2.5-coder:7b',
|
||||||
|
'llama3.1:8b',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getPrimaryModel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('getPrimaryModel', () => {
|
||||||
|
test('returns first model from comma-separated list', () => {
|
||||||
|
expect(getPrimaryModel('glm-4.7, glm-4.7-flash')).toBe('glm-4.7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the only model when single model is provided', () => {
|
||||||
|
expect(getPrimaryModel('llama3.1:8b')).toBe('llama3.1:8b')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the original string when input is empty', () => {
|
||||||
|
expect(getPrimaryModel('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns first model after trimming', () => {
|
||||||
|
expect(getPrimaryModel(' gpt-4o , gpt-4o-mini')).toBe('gpt-4o')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns first model when others are empty from trailing commas', () => {
|
||||||
|
expect(getPrimaryModel('claude-sonnet-4-6,,')).toBe('claude-sonnet-4-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── hasMultipleModels ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('hasMultipleModels', () => {
|
||||||
|
test('returns true when multiple models are present', () => {
|
||||||
|
expect(hasMultipleModels('glm-4.7, glm-4.7-flash')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for a single model', () => {
|
||||||
|
expect(hasMultipleModels('llama3.1:8b')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for empty string', () => {
|
||||||
|
expect(hasMultipleModels('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for whitespace-only string', () => {
|
||||||
|
expect(hasMultipleModels(' ')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when extra commas produce no extra models', () => {
|
||||||
|
expect(hasMultipleModels('gpt-4o,,')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for three models', () => {
|
||||||
|
expect(hasMultipleModels('a, b, c')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
src/utils/providerModels.ts
Normal file
33
src/utils/providerModels.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for parsing comma-separated model names in provider profiles.
|
||||||
|
*
|
||||||
|
* Example: "glm-4.7, glm-4.7-flash" -> ["glm-4.7", "glm-4.7-flash"]
|
||||||
|
* Single model: "llama3.1:8b" -> ["llama3.1:8b"]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a comma-separated model field into an array of trimmed model names,
|
||||||
|
* filtering out any empty entries.
|
||||||
|
*/
|
||||||
|
export function parseModelList(modelField: string): string[] {
|
||||||
|
return modelField
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first (primary) model from a comma-separated model field.
|
||||||
|
* Falls back to the original string if parsing yields no results.
|
||||||
|
*/
|
||||||
|
export function getPrimaryModel(modelField: string): string {
|
||||||
|
const models = parseModelList(modelField)
|
||||||
|
return models.length > 0 ? models[0] : modelField
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the model field contains more than one model.
|
||||||
|
*/
|
||||||
|
export function hasMultipleModels(modelField: string): boolean {
|
||||||
|
return parseModelList(modelField).length > 1
|
||||||
|
}
|
||||||
@@ -6,29 +6,29 @@ import {
|
|||||||
isCodexBaseUrl,
|
isCodexBaseUrl,
|
||||||
resolveCodexApiCredentials,
|
resolveCodexApiCredentials,
|
||||||
resolveProviderRequest,
|
resolveProviderRequest,
|
||||||
} from '../services/api/providerConfig.ts'
|
} from '../services/api/providerConfig.js'
|
||||||
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
|
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
|
||||||
import {
|
import {
|
||||||
getGoalDefaultOpenAIModel,
|
getGoalDefaultOpenAIModel,
|
||||||
normalizeRecommendationGoal,
|
normalizeRecommendationGoal,
|
||||||
type RecommendationGoal,
|
type RecommendationGoal,
|
||||||
} from './providerRecommendation.ts'
|
} from './providerRecommendation.js'
|
||||||
import { readGeminiAccessToken } from './geminiCredentials.ts'
|
import { readGeminiAccessToken } from './geminiCredentials.js'
|
||||||
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
|
import { getOllamaChatBaseUrl } from './providerDiscovery.js'
|
||||||
import { getProviderValidationError } from './providerValidation.ts'
|
import { getProviderValidationError } from './providerValidation.js'
|
||||||
import {
|
import {
|
||||||
maskSecretForDisplay,
|
maskSecretForDisplay,
|
||||||
redactSecretValueForDisplay,
|
redactSecretValueForDisplay,
|
||||||
sanitizeApiKey,
|
sanitizeApiKey,
|
||||||
sanitizeProviderConfigValue,
|
sanitizeProviderConfigValue,
|
||||||
} from './providerSecrets.ts'
|
} from './providerSecrets.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
maskSecretForDisplay,
|
maskSecretForDisplay,
|
||||||
redactSecretValueForDisplay,
|
redactSecretValueForDisplay,
|
||||||
sanitizeApiKey,
|
sanitizeApiKey,
|
||||||
sanitizeProviderConfigValue,
|
sanitizeProviderConfigValue,
|
||||||
} from './providerSecrets.ts'
|
} from './providerSecrets.js'
|
||||||
|
|
||||||
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
||||||
export const DEFAULT_GEMINI_BASE_URL =
|
export const DEFAULT_GEMINI_BASE_URL =
|
||||||
@@ -57,18 +57,28 @@ const PROFILE_ENV_KEYS = [
|
|||||||
'GEMINI_MODEL',
|
'GEMINI_MODEL',
|
||||||
'GEMINI_BASE_URL',
|
'GEMINI_BASE_URL',
|
||||||
'GOOGLE_API_KEY',
|
'GOOGLE_API_KEY',
|
||||||
|
'NVIDIA_NIM',
|
||||||
|
'NVIDIA_API_KEY',
|
||||||
|
'NVIDIA_MODEL',
|
||||||
|
'MINIMAX_API_KEY',
|
||||||
|
'MINIMAX_BASE_URL',
|
||||||
|
'MINIMAX_MODEL',
|
||||||
'MISTRAL_BASE_URL',
|
'MISTRAL_BASE_URL',
|
||||||
'MISTRAL_API_KEY',
|
'MISTRAL_API_KEY',
|
||||||
'MISTRAL_MODEL',
|
'MISTRAL_MODEL',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type ProviderProfile =
|
const SECRET_ENV_KEYS = [
|
||||||
| 'openai'
|
'OPENAI_API_KEY',
|
||||||
| 'ollama'
|
'CODEX_API_KEY',
|
||||||
| 'codex'
|
'GEMINI_API_KEY',
|
||||||
| 'gemini'
|
'GOOGLE_API_KEY',
|
||||||
| 'atomic-chat'
|
'NVIDIA_API_KEY',
|
||||||
| 'mistral'
|
'MINIMAX_API_KEY',
|
||||||
|
'MISTRAL_API_KEY',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'nvidia-nim' | 'minimax' | 'mistral'
|
||||||
|
|
||||||
export type ProfileEnv = {
|
export type ProfileEnv = {
|
||||||
OPENAI_BASE_URL?: string
|
OPENAI_BASE_URL?: string
|
||||||
@@ -82,6 +92,12 @@ export type ProfileEnv = {
|
|||||||
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
|
GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc'
|
||||||
GEMINI_MODEL?: string
|
GEMINI_MODEL?: string
|
||||||
GEMINI_BASE_URL?: string
|
GEMINI_BASE_URL?: string
|
||||||
|
GOOGLE_API_KEY?: string
|
||||||
|
NVIDIA_NIM?: string
|
||||||
|
NVIDIA_API_KEY?: string
|
||||||
|
MINIMAX_API_KEY?: string
|
||||||
|
MINIMAX_BASE_URL?: string
|
||||||
|
MINIMAX_MODEL?: string
|
||||||
MISTRAL_BASE_URL?: string
|
MISTRAL_BASE_URL?: string
|
||||||
MISTRAL_API_KEY?: string
|
MISTRAL_API_KEY?: string
|
||||||
MISTRAL_MODEL?: string
|
MISTRAL_MODEL?: string
|
||||||
@@ -93,6 +109,19 @@ export type ProfileFile = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretValueSource = Partial<
|
||||||
|
Record<
|
||||||
|
| 'OPENAI_API_KEY'
|
||||||
|
| 'CODEX_API_KEY'
|
||||||
|
| 'GEMINI_API_KEY'
|
||||||
|
| 'GOOGLE_API_KEY'
|
||||||
|
| 'NVIDIA_API_KEY'
|
||||||
|
| 'MINIMAX_API_KEY'
|
||||||
|
| 'MISTRAL_API_KEY',
|
||||||
|
string | undefined
|
||||||
|
>
|
||||||
|
>
|
||||||
|
|
||||||
type ProfileFileLocation = {
|
type ProfileFileLocation = {
|
||||||
cwd?: string
|
cwd?: string
|
||||||
filePath?: string
|
filePath?: string
|
||||||
@@ -113,6 +142,8 @@ export function isProviderProfile(value: unknown): value is ProviderProfile {
|
|||||||
value === 'codex' ||
|
value === 'codex' ||
|
||||||
value === 'gemini' ||
|
value === 'gemini' ||
|
||||||
value === 'atomic-chat' ||
|
value === 'atomic-chat' ||
|
||||||
|
value === 'nvidia-nim' ||
|
||||||
|
value === 'minimax' ||
|
||||||
value === 'mistral'
|
value === 'mistral'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,6 +174,67 @@ export function buildAtomicChatProfileEnv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildNvidiaNimProfileEnv(options: {
|
||||||
|
model?: string | null
|
||||||
|
baseUrl?: string | null
|
||||||
|
apiKey?: string | null
|
||||||
|
processEnv?: NodeJS.ProcessEnv
|
||||||
|
}): ProfileEnv | null {
|
||||||
|
const processEnv = options.processEnv ?? process.env
|
||||||
|
const key = sanitizeApiKey(options.apiKey ?? processEnv.NVIDIA_API_KEY)
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBaseUrl = 'https://integrate.api.nvidia.com/v1'
|
||||||
|
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPENAI_BASE_URL:
|
||||||
|
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
|
||||||
|
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
|
||||||
|
defaultBaseUrl,
|
||||||
|
OPENAI_MODEL:
|
||||||
|
sanitizeProviderConfigValue(options.model, secretSource) ||
|
||||||
|
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
|
||||||
|
'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
OPENAI_API_KEY: key,
|
||||||
|
NVIDIA_NIM: '1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMiniMaxProfileEnv(options: {
|
||||||
|
model?: string | null
|
||||||
|
baseUrl?: string | null
|
||||||
|
apiKey?: string | null
|
||||||
|
processEnv?: NodeJS.ProcessEnv
|
||||||
|
}): ProfileEnv | null {
|
||||||
|
const processEnv = options.processEnv ?? process.env
|
||||||
|
const key = sanitizeApiKey(options.apiKey ?? processEnv.MINIMAX_API_KEY)
|
||||||
|
if (!key) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBaseUrl = 'https://api.minimax.io/v1'
|
||||||
|
const defaultModel = 'MiniMax-M2.5'
|
||||||
|
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
|
||||||
|
|
||||||
|
return {
|
||||||
|
OPENAI_BASE_URL:
|
||||||
|
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
|
||||||
|
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
|
||||||
|
defaultBaseUrl,
|
||||||
|
OPENAI_MODEL:
|
||||||
|
sanitizeProviderConfigValue(options.model, secretSource) ||
|
||||||
|
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
|
||||||
|
defaultModel,
|
||||||
|
OPENAI_API_KEY: key,
|
||||||
|
MINIMAX_API_KEY: key,
|
||||||
|
MINIMAX_BASE_URL: defaultBaseUrl,
|
||||||
|
MINIMAX_MODEL: defaultModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildGeminiProfileEnv(options: {
|
export function buildGeminiProfileEnv(options: {
|
||||||
model?: string | null
|
model?: string | null
|
||||||
baseUrl?: string | null
|
baseUrl?: string | null
|
||||||
@@ -161,15 +253,13 @@ export function buildGeminiProfileEnv(options: {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secretSource: SecretValueSource = key ? { GEMINI_API_KEY: key } : {}
|
||||||
|
|
||||||
const env: ProfileEnv = {
|
const env: ProfileEnv = {
|
||||||
GEMINI_AUTH_MODE: authMode,
|
GEMINI_AUTH_MODE: authMode,
|
||||||
GEMINI_MODEL:
|
GEMINI_MODEL:
|
||||||
sanitizeProviderConfigValue(options.model, { GEMINI_API_KEY: key }, processEnv) ||
|
sanitizeProviderConfigValue(options.model, secretSource) ||
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
|
||||||
processEnv.GEMINI_MODEL,
|
|
||||||
{ GEMINI_API_KEY: key },
|
|
||||||
processEnv,
|
|
||||||
) ||
|
|
||||||
DEFAULT_GEMINI_MODEL,
|
DEFAULT_GEMINI_MODEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,12 +268,8 @@ export function buildGeminiProfileEnv(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
sanitizeProviderConfigValue(options.baseUrl, { GEMINI_API_KEY: key }, processEnv) ||
|
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(processEnv.GEMINI_BASE_URL, secretSource)
|
||||||
processEnv.GEMINI_BASE_URL,
|
|
||||||
{ GEMINI_API_KEY: key },
|
|
||||||
processEnv,
|
|
||||||
)
|
|
||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
env.GEMINI_BASE_URL = baseUrl
|
env.GEMINI_BASE_URL = baseUrl
|
||||||
}
|
}
|
||||||
@@ -205,15 +291,14 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
const defaultModel = getGoalDefaultOpenAIModel(options.goal)
|
||||||
|
const secretSource: SecretValueSource = { OPENAI_API_KEY: key }
|
||||||
const shellOpenAIModel = sanitizeProviderConfigValue(
|
const shellOpenAIModel = sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_MODEL,
|
processEnv.OPENAI_MODEL,
|
||||||
{ OPENAI_API_KEY: key },
|
secretSource,
|
||||||
processEnv,
|
|
||||||
)
|
)
|
||||||
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_BASE_URL,
|
processEnv.OPENAI_BASE_URL,
|
||||||
{ OPENAI_API_KEY: key },
|
secretSource,
|
||||||
processEnv,
|
|
||||||
)
|
)
|
||||||
const shellOpenAIRequest = resolveProviderRequest({
|
const shellOpenAIRequest = resolveProviderRequest({
|
||||||
model: shellOpenAIModel,
|
model: shellOpenAIModel,
|
||||||
@@ -224,19 +309,11 @@ export function buildOpenAIProfileEnv(options: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
OPENAI_BASE_URL:
|
OPENAI_BASE_URL:
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(options.baseUrl, secretSource) ||
|
||||||
options.baseUrl,
|
|
||||||
{ OPENAI_API_KEY: key },
|
|
||||||
processEnv,
|
|
||||||
) ||
|
|
||||||
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIBaseUrl : undefined) ||
|
||||||
DEFAULT_OPENAI_BASE_URL,
|
DEFAULT_OPENAI_BASE_URL,
|
||||||
OPENAI_MODEL:
|
OPENAI_MODEL:
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(options.model, secretSource) ||
|
||||||
options.model,
|
|
||||||
{ OPENAI_API_KEY: key },
|
|
||||||
processEnv,
|
|
||||||
) ||
|
|
||||||
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
(useShellOpenAIConfig ? shellOpenAIModel : undefined) ||
|
||||||
defaultModel,
|
defaultModel,
|
||||||
OPENAI_API_KEY: key,
|
OPENAI_API_KEY: key,
|
||||||
@@ -293,21 +370,19 @@ export function buildMistralProfileEnv(options: {
|
|||||||
const env: ProfileEnv = {
|
const env: ProfileEnv = {
|
||||||
MISTRAL_API_KEY: key,
|
MISTRAL_API_KEY: key,
|
||||||
MISTRAL_MODEL:
|
MISTRAL_MODEL:
|
||||||
sanitizeProviderConfigValue(options.model, { MISTRAL_API_KEY: key }, processEnv) ||
|
sanitizeProviderConfigValue(options.model, { MISTRAL_API_KEY: key }) ||
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(
|
||||||
processEnv.MISTRAL_MODEL,
|
processEnv.MISTRAL_MODEL,
|
||||||
{ MISTRAL_API_KEY: key },
|
{ MISTRAL_API_KEY: key },
|
||||||
processEnv,
|
|
||||||
) ||
|
) ||
|
||||||
DEFAULT_MISTRAL_MODEL,
|
DEFAULT_MISTRAL_MODEL,
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
sanitizeProviderConfigValue(options.baseUrl, { MISTRAL_API_KEY: key }, processEnv) ||
|
sanitizeProviderConfigValue(options.baseUrl, { MISTRAL_API_KEY: key }) ||
|
||||||
sanitizeProviderConfigValue(
|
sanitizeProviderConfigValue(
|
||||||
processEnv.MISTRAL_BASE_URL,
|
processEnv.MISTRAL_BASE_URL,
|
||||||
{ MISTRAL_API_KEY: key },
|
{ MISTRAL_API_KEY: key },
|
||||||
processEnv,
|
|
||||||
)
|
)
|
||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
env.MISTRAL_BASE_URL = baseUrl
|
env.MISTRAL_BASE_URL = baseUrl
|
||||||
@@ -465,11 +540,11 @@ export async function buildLaunchEnv(options: {
|
|||||||
)
|
)
|
||||||
const shellOpenAIModel = sanitizeProviderConfigValue(
|
const shellOpenAIModel = sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_MODEL,
|
processEnv.OPENAI_MODEL,
|
||||||
processEnv,
|
processEnv as SecretValueSource,
|
||||||
)
|
)
|
||||||
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
const shellOpenAIBaseUrl = sanitizeProviderConfigValue(
|
||||||
processEnv.OPENAI_BASE_URL,
|
processEnv.OPENAI_BASE_URL,
|
||||||
processEnv,
|
processEnv as SecretValueSource,
|
||||||
)
|
)
|
||||||
const persistedGeminiModel = sanitizeProviderConfigValue(
|
const persistedGeminiModel = sanitizeProviderConfigValue(
|
||||||
persistedEnv.GEMINI_MODEL,
|
persistedEnv.GEMINI_MODEL,
|
||||||
@@ -481,11 +556,11 @@ export async function buildLaunchEnv(options: {
|
|||||||
)
|
)
|
||||||
const shellGeminiModel = sanitizeProviderConfigValue(
|
const shellGeminiModel = sanitizeProviderConfigValue(
|
||||||
processEnv.GEMINI_MODEL,
|
processEnv.GEMINI_MODEL,
|
||||||
processEnv,
|
processEnv as SecretValueSource,
|
||||||
)
|
)
|
||||||
const shellGeminiBaseUrl = sanitizeProviderConfigValue(
|
const shellGeminiBaseUrl = sanitizeProviderConfigValue(
|
||||||
processEnv.GEMINI_BASE_URL,
|
processEnv.GEMINI_BASE_URL,
|
||||||
processEnv,
|
processEnv as SecretValueSource,
|
||||||
)
|
)
|
||||||
const shellGeminiAccessToken =
|
const shellGeminiAccessToken =
|
||||||
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
|
processEnv.GEMINI_ACCESS_TOKEN?.trim() || undefined
|
||||||
@@ -567,19 +642,15 @@ export async function buildLaunchEnv(options: {
|
|||||||
|
|
||||||
const shellMistralModel = sanitizeProviderConfigValue(
|
const shellMistralModel = sanitizeProviderConfigValue(
|
||||||
processEnv.MISTRAL_MODEL,
|
processEnv.MISTRAL_MODEL,
|
||||||
processEnv,
|
|
||||||
)
|
)
|
||||||
const persistedMistralModel = sanitizeProviderConfigValue(
|
const persistedMistralModel = sanitizeProviderConfigValue(
|
||||||
persistedEnv.MISTRAL_MODEL,
|
persistedEnv.MISTRAL_MODEL,
|
||||||
persistedEnv,
|
|
||||||
)
|
)
|
||||||
const shellMistralBaseUrl = sanitizeProviderConfigValue(
|
const shellMistralBaseUrl = sanitizeProviderConfigValue(
|
||||||
processEnv.MISTRAL_BASE_URL,
|
processEnv.MISTRAL_BASE_URL,
|
||||||
processEnv,
|
|
||||||
)
|
)
|
||||||
const persistedMistralBaseUrl = sanitizeProviderConfigValue(
|
const persistedMistralBaseUrl = sanitizeProviderConfigValue(
|
||||||
persistedEnv.MISTRAL_BASE_URL,
|
persistedEnv.MISTRAL_BASE_URL,
|
||||||
persistedEnv,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
env.MISTRAL_MODEL =
|
env.MISTRAL_MODEL =
|
||||||
|
|||||||
@@ -139,6 +139,39 @@ describe('applyProviderProfileToProcessEnv', () => {
|
|||||||
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
expect(getFreshAPIProvider()).toBe('firstParty')
|
expect(getFreshAPIProvider()).toBe('firstParty')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('openai profile with multi-model string sets only first model in OPENAI_MODEL', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('glm-4.7')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anthropic profile with multi-model string sets only first model in ANTHROPIC_MODEL', async () => {
|
||||||
|
const { applyProviderProfileToProcessEnv } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
applyProviderProfileToProcessEnv(
|
||||||
|
buildProfile({
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6, claude-opus-4-6',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('applyActiveProviderProfileFromConfig', () => {
|
describe('applyActiveProviderProfileFromConfig', () => {
|
||||||
@@ -361,6 +394,169 @@ describe('getProviderPresetDefaults', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('setActiveProviderProfile', () => {
|
||||||
|
test('sets OPENAI_MODEL env var when switching to an openai-type provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('openai_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('openai_prof')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'openai_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sets ANTHROPIC_MODEL env var when switching to an anthropic-type provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [anthropicProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('anthro_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_MODEL).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'anthro_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears openai model env and sets anthropic model env when switching from openai to anthropic provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'sk-openai-key',
|
||||||
|
})
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
apiKey: 'sk-ant-key',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile, anthropicProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// First activate the openai profile
|
||||||
|
setActiveProviderProfile('openai_prof')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
|
||||||
|
// Now switch to the anthropic profile
|
||||||
|
const result = setActiveProviderProfile('anthro_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_MODEL).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBeUndefined()
|
||||||
|
expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'anthro_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears anthropic model env and sets openai model env when switching from anthropic to openai provider', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const anthropicProfile = buildProfile({
|
||||||
|
id: 'anthro_prof',
|
||||||
|
name: 'Anthropic Provider',
|
||||||
|
provider: 'anthropic',
|
||||||
|
baseUrl: 'https://api.anthropic.com',
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
apiKey: 'sk-ant-key',
|
||||||
|
})
|
||||||
|
const openaiProfile = buildProfile({
|
||||||
|
id: 'openai_prof',
|
||||||
|
name: 'OpenAI Provider',
|
||||||
|
provider: 'openai',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
apiKey: 'sk-openai-key',
|
||||||
|
})
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [anthropicProfile, openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
// First activate the anthropic profile
|
||||||
|
setActiveProviderProfile('anthro_prof')
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||||
|
|
||||||
|
// Now switch to the openai profile
|
||||||
|
const result = setActiveProviderProfile('openai_prof')
|
||||||
|
|
||||||
|
expect(result?.id).toBe('openai_prof')
|
||||||
|
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||||
|
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
|
||||||
|
// ANTHROPIC_MODEL is set to the profile model for all provider types
|
||||||
|
expect(process.env.ANTHROPIC_MODEL).toBe('gpt-4o')
|
||||||
|
expect(process.env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||||
|
expect(process.env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||||
|
expect(process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBe(
|
||||||
|
'openai_prof',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for non-existent profile id', async () => {
|
||||||
|
const { setActiveProviderProfile } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
const openaiProfile = buildProfile({ id: 'existing_prof' })
|
||||||
|
|
||||||
|
saveMockGlobalConfig(current => ({
|
||||||
|
...current,
|
||||||
|
providerProfiles: [openaiProfile],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = setActiveProviderProfile('nonexistent_prof')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('deleteProviderProfile', () => {
|
describe('deleteProviderProfile', () => {
|
||||||
test('deleting final profile clears provider env when active profile applied it', async () => {
|
test('deleting final profile clears provider env when active profile applied it', async () => {
|
||||||
const {
|
const {
|
||||||
@@ -429,3 +625,82 @@ describe('deleteProviderProfile', () => {
|
|||||||
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
expect(process.env.OPENAI_MODEL).toBe('qwen2.5:3b')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getProfileModelOptions', () => {
|
||||||
|
test('generates options for multi-model profile', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Test Provider',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: 'glm-4.7', label: 'glm-4.7', description: 'Provider: Test Provider' },
|
||||||
|
{ value: 'glm-4.7-flash', label: 'glm-4.7-flash', description: 'Provider: Test Provider' },
|
||||||
|
{ value: 'glm-4.7-plus', label: 'glm-4.7-plus', description: 'Provider: Test Provider' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns single option for single-model profile', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Single Model',
|
||||||
|
model: 'llama3.1:8b',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ value: 'llama3.1:8b', label: 'llama3.1:8b', description: 'Provider: Single Model' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array for empty model field', async () => {
|
||||||
|
const { getProfileModelOptions } =
|
||||||
|
await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
const options = getProfileModelOptions(
|
||||||
|
buildProfile({
|
||||||
|
name: 'Empty',
|
||||||
|
model: '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setActiveProviderProfile model cache', () => {
|
||||||
|
test('populates model cache with all models from multi-model profile on activation', async () => {
|
||||||
|
const {
|
||||||
|
setActiveProviderProfile,
|
||||||
|
getActiveOpenAIModelOptionsCache,
|
||||||
|
} = await importFreshProviderProfileModules()
|
||||||
|
|
||||||
|
mockConfigState = {
|
||||||
|
...createMockConfigState(),
|
||||||
|
providerProfiles: [
|
||||||
|
buildProfile({
|
||||||
|
id: 'multi_provider',
|
||||||
|
name: 'Multi Provider',
|
||||||
|
model: 'glm-4.7, glm-4.7-flash, glm-4.7-plus',
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveProviderProfile('multi_provider')
|
||||||
|
|
||||||
|
const cache = getActiveOpenAIModelOptionsCache()
|
||||||
|
const cacheValues = cache.map(opt => opt.value)
|
||||||
|
expect(cacheValues).toContain('glm-4.7')
|
||||||
|
expect(cacheValues).toContain('glm-4.7-flash')
|
||||||
|
expect(cacheValues).toContain('glm-4.7-plus')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type ProviderProfile,
|
type ProviderProfile,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import type { ModelOption } from './model/modelOptions.js'
|
import type { ModelOption } from './model/modelOptions.js'
|
||||||
|
import { getPrimaryModel, parseModelList } from './providerModels.js'
|
||||||
|
|
||||||
export type ProviderPreset =
|
export type ProviderPreset =
|
||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
@@ -19,7 +20,11 @@ export type ProviderPreset =
|
|||||||
| 'azure-openai'
|
| 'azure-openai'
|
||||||
| 'openrouter'
|
| 'openrouter'
|
||||||
| 'lmstudio'
|
| 'lmstudio'
|
||||||
|
| 'dashscope-cn'
|
||||||
|
| 'dashscope-intl'
|
||||||
| 'custom'
|
| 'custom'
|
||||||
|
| 'nvidia-nim'
|
||||||
|
| 'minimax'
|
||||||
|
|
||||||
export type ProviderProfileInput = {
|
export type ProviderProfileInput = {
|
||||||
provider?: ProviderProfile['provider']
|
provider?: ProviderProfile['provider']
|
||||||
@@ -217,6 +222,24 @@ export function getProviderPresetDefaults(
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
requiresApiKey: false,
|
requiresApiKey: false,
|
||||||
}
|
}
|
||||||
|
case 'dashscope-cn':
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'Alibaba Coding Plan (China)',
|
||||||
|
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
model: 'qwen3.6-plus',
|
||||||
|
apiKey: process.env.DASHSCOPE_API_KEY ?? '',
|
||||||
|
requiresApiKey: true,
|
||||||
|
}
|
||||||
|
case 'dashscope-intl':
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'Alibaba Coding Plan',
|
||||||
|
baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||||
|
model: 'qwen3.6-plus',
|
||||||
|
apiKey: process.env.DASHSCOPE_API_KEY ?? '',
|
||||||
|
requiresApiKey: true,
|
||||||
|
}
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return {
|
return {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
@@ -229,6 +252,24 @@ export function getProviderPresetDefaults(
|
|||||||
apiKey: process.env.OPENAI_API_KEY ?? '',
|
apiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
requiresApiKey: false,
|
requiresApiKey: false,
|
||||||
}
|
}
|
||||||
|
case 'nvidia-nim':
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'NVIDIA NIM',
|
||||||
|
baseUrl: 'https://integrate.api.nvidia.com/v1',
|
||||||
|
model: 'nvidia/llama-3.1-nemotron-70b-instruct',
|
||||||
|
apiKey: process.env.NVIDIA_API_KEY ?? '',
|
||||||
|
requiresApiKey: true,
|
||||||
|
}
|
||||||
|
case 'minimax':
|
||||||
|
return {
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'MiniMax',
|
||||||
|
baseUrl: 'https://api.minimax.io/v1',
|
||||||
|
model: 'MiniMax-M2.5',
|
||||||
|
apiKey: process.env.MINIMAX_API_KEY ?? '',
|
||||||
|
requiresApiKey: true,
|
||||||
|
}
|
||||||
case 'ollama':
|
case 'ollama':
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -311,7 +352,7 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
return (
|
return (
|
||||||
!hasProviderSelectionFlags(processEnv) &&
|
!hasProviderSelectionFlags(processEnv) &&
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_BASE_URL, profile.baseUrl) &&
|
sameOptionalEnvValue(processEnv.ANTHROPIC_BASE_URL, profile.baseUrl) &&
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_MODEL, profile.model) &&
|
sameOptionalEnvValue(processEnv.ANTHROPIC_MODEL, getPrimaryModel(profile.model)) &&
|
||||||
(!includeApiKey ||
|
(!includeApiKey ||
|
||||||
sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey))
|
sameOptionalEnvValue(processEnv.ANTHROPIC_API_KEY, profile.apiKey))
|
||||||
)
|
)
|
||||||
@@ -326,7 +367,7 @@ function isProcessEnvAlignedWithProfile(
|
|||||||
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
|
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
|
||||||
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
sameOptionalEnvValue(processEnv.OPENAI_BASE_URL, profile.baseUrl) &&
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_MODEL, profile.model) &&
|
sameOptionalEnvValue(processEnv.OPENAI_MODEL, getPrimaryModel(profile.model)) &&
|
||||||
(!includeApiKey ||
|
(!includeApiKey ||
|
||||||
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey))
|
sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey))
|
||||||
)
|
)
|
||||||
@@ -365,6 +406,11 @@ export function clearProviderProfileEnvFromProcessEnv(
|
|||||||
delete processEnv.ANTHROPIC_API_KEY
|
delete processEnv.ANTHROPIC_API_KEY
|
||||||
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
|
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
|
||||||
delete processEnv[PROFILE_ENV_APPLIED_ID]
|
delete processEnv[PROFILE_ENV_APPLIED_ID]
|
||||||
|
|
||||||
|
// Clear provider-specific API keys
|
||||||
|
delete processEnv.MINIMAX_API_KEY
|
||||||
|
delete processEnv.NVIDIA_API_KEY
|
||||||
|
delete processEnv.NVIDIA_NIM
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void {
|
export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void {
|
||||||
@@ -372,7 +418,7 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
process.env[PROFILE_ENV_APPLIED_FLAG] = '1'
|
||||||
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
process.env[PROFILE_ENV_APPLIED_ID] = profile.id
|
||||||
|
|
||||||
process.env.ANTHROPIC_MODEL = profile.model
|
process.env.ANTHROPIC_MODEL = getPrimaryModel(profile.model)
|
||||||
if (profile.provider === 'anthropic') {
|
if (profile.provider === 'anthropic') {
|
||||||
process.env.ANTHROPIC_BASE_URL = profile.baseUrl
|
process.env.ANTHROPIC_BASE_URL = profile.baseUrl
|
||||||
|
|
||||||
@@ -391,10 +437,18 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
|
|||||||
|
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
process.env.OPENAI_BASE_URL = profile.baseUrl
|
process.env.OPENAI_BASE_URL = profile.baseUrl
|
||||||
process.env.OPENAI_MODEL = profile.model
|
process.env.OPENAI_MODEL = getPrimaryModel(profile.model)
|
||||||
|
|
||||||
if (profile.apiKey) {
|
if (profile.apiKey) {
|
||||||
process.env.OPENAI_API_KEY = profile.apiKey
|
process.env.OPENAI_API_KEY = profile.apiKey
|
||||||
|
// Also set provider-specific API keys for detection
|
||||||
|
const baseUrl = profile.baseUrl.toLowerCase()
|
||||||
|
if (baseUrl.includes('minimax')) {
|
||||||
|
process.env.MINIMAX_API_KEY = profile.apiKey
|
||||||
|
}
|
||||||
|
if (baseUrl.includes('nvidia') || baseUrl.includes('integrate.api.nvidia')) {
|
||||||
|
process.env.NVIDIA_API_KEY = profile.apiKey
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
delete process.env.OPENAI_API_KEY
|
delete process.env.OPENAI_API_KEY
|
||||||
}
|
}
|
||||||
@@ -548,6 +602,16 @@ export function persistActiveProviderProfileModel(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the model is already part of the profile's model list, don't
|
||||||
|
// overwrite the field. This preserves comma-separated model lists like
|
||||||
|
// "glm-4.5, glm-4.7". Switching between models in the list is a
|
||||||
|
// session-level choice handled by mainLoopModelOverride, not a profile
|
||||||
|
// edit — the profile's model list should only change via explicit edit.
|
||||||
|
const existingModels = parseModelList(activeProfile.model)
|
||||||
|
if (existingModels.includes(nextModel)) {
|
||||||
|
return activeProfile
|
||||||
|
}
|
||||||
|
|
||||||
saveGlobalConfig(current => {
|
saveGlobalConfig(current => {
|
||||||
const currentProfiles = getProviderProfiles(current)
|
const currentProfiles = getProviderProfiles(current)
|
||||||
const profileIndex = currentProfiles.findIndex(
|
const profileIndex = currentProfiles.findIndex(
|
||||||
@@ -590,6 +654,23 @@ export function persistActiveProviderProfileModel(
|
|||||||
return resolvedProfile
|
return resolvedProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate model options from a provider profile's model field.
|
||||||
|
* Each comma-separated model becomes a separate option in the picker.
|
||||||
|
*/
|
||||||
|
export function getProfileModelOptions(profile: ProviderProfile): ModelOption[] {
|
||||||
|
const models = parseModelList(profile.model)
|
||||||
|
if (models.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.map(model => ({
|
||||||
|
value: model,
|
||||||
|
label: model,
|
||||||
|
description: `Provider: ${profile.name}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function setActiveProviderProfile(
|
export function setActiveProviderProfile(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
): ProviderProfile | null {
|
): ProviderProfile | null {
|
||||||
@@ -601,10 +682,20 @@ export function setActiveProviderProfile(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileModelOptions = getProfileModelOptions(activeProfile)
|
||||||
|
|
||||||
saveGlobalConfig(config => ({
|
saveGlobalConfig(config => ({
|
||||||
...config,
|
...config,
|
||||||
activeProviderProfileId: profileId,
|
activeProviderProfileId: profileId,
|
||||||
openaiAdditionalModelOptionsCache: getModelCacheByProfile(profileId, config),
|
openaiAdditionalModelOptionsCache: profileModelOptions.length > 0
|
||||||
|
? profileModelOptions
|
||||||
|
: getModelCacheByProfile(profileId, config),
|
||||||
|
openaiAdditionalModelOptionsCacheByProfile: {
|
||||||
|
...(config.openaiAdditionalModelOptionsCacheByProfile ?? {}),
|
||||||
|
[profileId]: profileModelOptions.length > 0
|
||||||
|
? profileModelOptions
|
||||||
|
: (config.openaiAdditionalModelOptionsCacheByProfile?.[profileId] ?? []),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
applyProviderProfileToProcessEnv(activeProfile)
|
applyProviderProfileToProcessEnv(activeProfile)
|
||||||
|
|||||||
33
src/utils/swarm/spawnUtils.test.ts
Normal file
33
src/utils/swarm/spawnUtils.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { buildInheritedEnvVars } from './spawnUtils.js'
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
Object.assign(process.env, ORIGINAL_ENV)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildInheritedEnvVars marks spawned teammates as host-managed for provider routing', () => {
|
||||||
|
const envVars = buildInheritedEnvVars()
|
||||||
|
|
||||||
|
expect(envVars).toContain('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildInheritedEnvVars forwards PATH for source-built teammate tool lookups', () => {
|
||||||
|
process.env.PATH = '/custom/bin:/usr/bin'
|
||||||
|
|
||||||
|
const envVars = buildInheritedEnvVars()
|
||||||
|
|
||||||
|
expect(envVars).toContain('PATH=')
|
||||||
|
expect(envVars).toContain('/custom/bin\\:/usr/bin')
|
||||||
|
})
|
||||||
@@ -141,6 +141,9 @@ const TEAMMATE_ENV_VARS = [
|
|||||||
'NODE_EXTRA_CA_CERTS',
|
'NODE_EXTRA_CA_CERTS',
|
||||||
'REQUESTS_CA_BUNDLE',
|
'REQUESTS_CA_BUNDLE',
|
||||||
'CURL_CA_BUNDLE',
|
'CURL_CA_BUNDLE',
|
||||||
|
// Source builds may rely on user shell PATH for rg/node/bun and other tools.
|
||||||
|
// Forward it so teammates resolve the same toolchain as the parent session.
|
||||||
|
'PATH',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +152,13 @@ const TEAMMATE_ENV_VARS = [
|
|||||||
* plus any provider/config env vars that are set in the current process.
|
* plus any provider/config env vars that are set in the current process.
|
||||||
*/
|
*/
|
||||||
export function buildInheritedEnvVars(): string {
|
export function buildInheritedEnvVars(): string {
|
||||||
const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1']
|
const envVars = [
|
||||||
|
'CLAUDECODE=1',
|
||||||
|
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1',
|
||||||
|
// Teammates should inherit the leader-selected provider route instead of
|
||||||
|
// replaying persisted ~/.claude or settings.env provider defaults.
|
||||||
|
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1',
|
||||||
|
]
|
||||||
|
|
||||||
for (const key of TEAMMATE_ENV_VARS) {
|
for (const key of TEAMMATE_ENV_VARS) {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
|
|||||||
@@ -131,11 +131,15 @@ export function truncateToWidthNoEllipsis(
|
|||||||
* @param singleLine If true, also truncates at the first newline
|
* @param singleLine If true, also truncates at the first newline
|
||||||
* @returns The truncated string with ellipsis if needed
|
* @returns The truncated string with ellipsis if needed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function truncate(
|
export function truncate(
|
||||||
str: string,
|
str: string,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
singleLine: boolean = false,
|
singleLine: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
|
// Undefined or null protection
|
||||||
|
if (!str) return ''
|
||||||
|
|
||||||
let result = str
|
let result = str
|
||||||
|
|
||||||
// If singleLine is true, truncate at first newline
|
// If singleLine is true, truncate at first newline
|
||||||
|
|||||||
69
src/utils/worktree.test.ts
Normal file
69
src/utils/worktree.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
_resetGitWorktreeMutationLocksForTesting,
|
||||||
|
withGitWorktreeMutationLock,
|
||||||
|
} from './worktree.js'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetGitWorktreeMutationLocksForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('withGitWorktreeMutationLock serializes mutations for the same repo', async () => {
|
||||||
|
const order: string[] = []
|
||||||
|
let releaseFirst!: () => void
|
||||||
|
const firstGate = new Promise<void>(resolve => {
|
||||||
|
releaseFirst = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = withGitWorktreeMutationLock('/repo', async () => {
|
||||||
|
order.push('first:start')
|
||||||
|
await firstGate
|
||||||
|
order.push('first:end')
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = withGitWorktreeMutationLock('/repo', async () => {
|
||||||
|
order.push('second:start')
|
||||||
|
order.push('second:end')
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(order).toEqual(['first:start'])
|
||||||
|
|
||||||
|
releaseFirst()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
|
||||||
|
expect(order).toEqual([
|
||||||
|
'first:start',
|
||||||
|
'first:end',
|
||||||
|
'second:start',
|
||||||
|
'second:end',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('withGitWorktreeMutationLock does not serialize different repos', async () => {
|
||||||
|
const order: string[] = []
|
||||||
|
let releaseFirst!: () => void
|
||||||
|
const firstGate = new Promise<void>(resolve => {
|
||||||
|
releaseFirst = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
const first = withGitWorktreeMutationLock('/repo-a', async () => {
|
||||||
|
order.push('a:start')
|
||||||
|
await firstGate
|
||||||
|
order.push('a:end')
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = withGitWorktreeMutationLock('/repo-b', async () => {
|
||||||
|
order.push('b:start')
|
||||||
|
order.push('b:end')
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(order).toEqual(['a:start', 'b:start', 'b:end'])
|
||||||
|
|
||||||
|
releaseFirst()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
})
|
||||||
@@ -192,6 +192,36 @@ type WorktreeCreateResult =
|
|||||||
existed: false
|
existed: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gitWorktreeMutationLocks = new Map<string, Promise<void>>()
|
||||||
|
|
||||||
|
export async function withGitWorktreeMutationLock<T>(
|
||||||
|
repoRoot: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = gitWorktreeMutationLocks.get(repoRoot) ?? Promise.resolve()
|
||||||
|
let releaseCurrent!: () => void
|
||||||
|
const current = new Promise<void>(resolve => {
|
||||||
|
releaseCurrent = resolve
|
||||||
|
})
|
||||||
|
const next = previous.catch(() => {}).then(() => current)
|
||||||
|
gitWorktreeMutationLocks.set(repoRoot, next)
|
||||||
|
|
||||||
|
await previous.catch(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
releaseCurrent()
|
||||||
|
if (gitWorktreeMutationLocks.get(repoRoot) === next) {
|
||||||
|
gitWorktreeMutationLocks.delete(repoRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resetGitWorktreeMutationLocksForTesting(): void {
|
||||||
|
gitWorktreeMutationLocks.clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).
|
// Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).
|
||||||
// GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.
|
// GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.
|
||||||
// GIT_ASKPASS='' disables askpass GUI programs.
|
// GIT_ASKPASS='' disables askpass GUI programs.
|
||||||
@@ -254,124 +284,136 @@ async function getOrCreateWorktree(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New worktree: fetch base branch then add
|
return withGitWorktreeMutationLock(repoRoot, async () => {
|
||||||
await mkdir(worktreesDir(repoRoot), { recursive: true })
|
const lockedExistingHead = await readWorktreeHeadSha(worktreePath)
|
||||||
|
if (lockedExistingHead) {
|
||||||
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
|
return {
|
||||||
|
worktreePath,
|
||||||
let baseBranch: string
|
worktreeBranch,
|
||||||
let baseSha: string | null = null
|
headCommit: lockedExistingHead,
|
||||||
if (options?.prNumber) {
|
existed: true,
|
||||||
const { code: prFetchCode, stderr: prFetchStderr } =
|
}
|
||||||
await execFileNoThrowWithCwd(
|
|
||||||
gitExe(),
|
|
||||||
['fetch', 'origin', `pull/${options.prNumber}/head`],
|
|
||||||
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
|
|
||||||
)
|
|
||||||
if (prFetchCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
baseBranch = 'FETCH_HEAD'
|
|
||||||
} else {
|
// New worktree: fetch base branch then add
|
||||||
// If origin/<branch> already exists locally, skip fetch. In large repos
|
await mkdir(worktreesDir(repoRoot), { recursive: true })
|
||||||
// (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
|
|
||||||
// scan before even hitting the network. A slightly stale base is fine —
|
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
|
||||||
// the user can pull in the worktree if they want latest.
|
|
||||||
// resolveRef reads the loose/packed ref directly; when it succeeds we
|
let baseBranch: string
|
||||||
// already have the SHA, so the later rev-parse is skipped entirely.
|
let baseSha: string | null = null
|
||||||
const [defaultBranch, gitDir] = await Promise.all([
|
if (options?.prNumber) {
|
||||||
getDefaultBranch(),
|
const { code: prFetchCode, stderr: prFetchStderr } =
|
||||||
resolveGitDir(repoRoot),
|
await execFileNoThrowWithCwd(
|
||||||
])
|
gitExe(),
|
||||||
const originRef = `origin/${defaultBranch}`
|
['fetch', 'origin', `pull/${options.prNumber}/head`],
|
||||||
const originSha = gitDir
|
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
|
||||||
? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`)
|
)
|
||||||
: null
|
if (prFetchCode !== 0) {
|
||||||
if (originSha) {
|
throw new Error(
|
||||||
baseBranch = originRef
|
`Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
|
||||||
baseSha = originSha
|
)
|
||||||
|
}
|
||||||
|
baseBranch = 'FETCH_HEAD'
|
||||||
} else {
|
} else {
|
||||||
const { code: fetchCode } = await execFileNoThrowWithCwd(
|
// If origin/<branch> already exists locally, skip fetch. In large repos
|
||||||
gitExe(),
|
// (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
|
||||||
['fetch', 'origin', defaultBranch],
|
// scan before even hitting the network. A slightly stale base is fine —
|
||||||
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
|
// the user can pull in the worktree if they want latest.
|
||||||
)
|
// resolveRef reads the loose/packed ref directly; when it succeeds we
|
||||||
baseBranch = fetchCode === 0 ? originRef : 'HEAD'
|
// already have the SHA, so the later rev-parse is skipped entirely.
|
||||||
|
const [defaultBranch, gitDir] = await Promise.all([
|
||||||
|
getDefaultBranch(),
|
||||||
|
resolveGitDir(repoRoot),
|
||||||
|
])
|
||||||
|
const originRef = `origin/${defaultBranch}`
|
||||||
|
const originSha = gitDir
|
||||||
|
? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`)
|
||||||
|
: null
|
||||||
|
if (originSha) {
|
||||||
|
baseBranch = originRef
|
||||||
|
baseSha = originSha
|
||||||
|
} else {
|
||||||
|
const { code: fetchCode } = await execFileNoThrowWithCwd(
|
||||||
|
gitExe(),
|
||||||
|
['fetch', 'origin', defaultBranch],
|
||||||
|
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
|
||||||
|
)
|
||||||
|
baseBranch = fetchCode === 0 ? originRef : 'HEAD'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef
|
// For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef
|
||||||
// above only covers the "origin/<branch> already exists locally" case.
|
// above only covers the "origin/<branch> already exists locally" case.
|
||||||
if (!baseSha) {
|
if (!baseSha) {
|
||||||
const { stdout, code: shaCode } = await execFileNoThrowWithCwd(
|
const { stdout, code: shaCode } = await execFileNoThrowWithCwd(
|
||||||
gitExe(),
|
|
||||||
['rev-parse', baseBranch],
|
|
||||||
{ cwd: repoRoot },
|
|
||||||
)
|
|
||||||
if (shaCode !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
baseSha = stdout.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const sparsePaths = getInitialSettings().worktree?.sparsePaths
|
|
||||||
const addArgs = ['worktree', 'add']
|
|
||||||
if (sparsePaths?.length) {
|
|
||||||
addArgs.push('--no-checkout')
|
|
||||||
}
|
|
||||||
// -B (not -b): reset any orphan branch left behind by a removed worktree dir.
|
|
||||||
// Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create.
|
|
||||||
addArgs.push('-B', worktreeBranch, worktreePath, baseBranch)
|
|
||||||
|
|
||||||
const { code: createCode, stderr: createStderr } =
|
|
||||||
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
|
|
||||||
if (createCode !== 0) {
|
|
||||||
throw new Error(`Failed to create worktree: ${createStderr}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sparsePaths?.length) {
|
|
||||||
// If sparse-checkout or checkout fail after --no-checkout, the worktree
|
|
||||||
// is registered and HEAD is set but the working tree is empty. Next run's
|
|
||||||
// fast-resume (rev-parse HEAD) would succeed and present a broken worktree
|
|
||||||
// as "resumed". Tear it down before propagating the error.
|
|
||||||
const tearDown = async (msg: string): Promise<never> => {
|
|
||||||
await execFileNoThrowWithCwd(
|
|
||||||
gitExe(),
|
gitExe(),
|
||||||
['worktree', 'remove', '--force', worktreePath],
|
['rev-parse', baseBranch],
|
||||||
{ cwd: repoRoot },
|
{ cwd: repoRoot },
|
||||||
)
|
)
|
||||||
throw new Error(msg)
|
if (shaCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
baseSha = stdout.trim()
|
||||||
}
|
}
|
||||||
const { code: sparseCode, stderr: sparseErr } =
|
|
||||||
await execFileNoThrowWithCwd(
|
const sparsePaths = getInitialSettings().worktree?.sparsePaths
|
||||||
|
const addArgs = ['worktree', 'add']
|
||||||
|
if (sparsePaths?.length) {
|
||||||
|
addArgs.push('--no-checkout')
|
||||||
|
}
|
||||||
|
// -B (not -b): reset any orphan branch left behind by a removed worktree dir.
|
||||||
|
// Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create.
|
||||||
|
addArgs.push('-B', worktreeBranch, worktreePath, baseBranch)
|
||||||
|
|
||||||
|
const { code: createCode, stderr: createStderr } =
|
||||||
|
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
|
||||||
|
if (createCode !== 0) {
|
||||||
|
throw new Error(`Failed to create worktree: ${createStderr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sparsePaths?.length) {
|
||||||
|
// If sparse-checkout or checkout fail after --no-checkout, the worktree
|
||||||
|
// is registered and HEAD is set but the working tree is empty. Next run's
|
||||||
|
// fast-resume (rev-parse HEAD) would succeed and present a broken worktree
|
||||||
|
// as "resumed". Tear it down before propagating the error.
|
||||||
|
const tearDown = async (msg: string): Promise<never> => {
|
||||||
|
await execFileNoThrowWithCwd(
|
||||||
|
gitExe(),
|
||||||
|
['worktree', 'remove', '--force', worktreePath],
|
||||||
|
{ cwd: repoRoot },
|
||||||
|
)
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const { code: sparseCode, stderr: sparseErr } =
|
||||||
|
await execFileNoThrowWithCwd(
|
||||||
|
gitExe(),
|
||||||
|
['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
|
||||||
|
{ cwd: worktreePath },
|
||||||
|
)
|
||||||
|
if (sparseCode !== 0) {
|
||||||
|
await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`)
|
||||||
|
}
|
||||||
|
const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
|
||||||
gitExe(),
|
gitExe(),
|
||||||
['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
|
['checkout', 'HEAD'],
|
||||||
{ cwd: worktreePath },
|
{ cwd: worktreePath },
|
||||||
)
|
)
|
||||||
if (sparseCode !== 0) {
|
if (coCode !== 0) {
|
||||||
await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`)
|
await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
|
|
||||||
gitExe(),
|
|
||||||
['checkout', 'HEAD'],
|
|
||||||
{ cwd: worktreePath },
|
|
||||||
)
|
|
||||||
if (coCode !== 0) {
|
|
||||||
await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
worktreeBranch,
|
worktreeBranch,
|
||||||
headCommit: baseSha,
|
headCommit: baseSha,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
existed: false,
|
existed: false,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -984,39 +1026,41 @@ export async function removeAgentWorktree(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run from the main repo root, not the worktree (which we're about to delete)
|
return withGitWorktreeMutationLock(gitRoot, async () => {
|
||||||
const { code: removeCode, stderr: removeError } =
|
// Run from the main repo root, not the worktree (which we're about to delete)
|
||||||
await execFileNoThrowWithCwd(
|
const { code: removeCode, stderr: removeError } =
|
||||||
gitExe(),
|
await execFileNoThrowWithCwd(
|
||||||
['worktree', 'remove', '--force', worktreePath],
|
gitExe(),
|
||||||
{ cwd: gitRoot },
|
['worktree', 'remove', '--force', worktreePath],
|
||||||
)
|
{ cwd: gitRoot },
|
||||||
|
)
|
||||||
|
|
||||||
if (removeCode !== 0) {
|
if (removeCode !== 0) {
|
||||||
logForDebugging(`Failed to remove agent worktree: ${removeError}`, {
|
logForDebugging(`Failed to remove agent worktree: ${removeError}`, {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
logForDebugging(`Removed agent worktree at: ${worktreePath}`)
|
logForDebugging(`Removed agent worktree at: ${worktreePath}`)
|
||||||
|
|
||||||
if (!worktreeBranch) {
|
if (!worktreeBranch) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the temporary worktree branch from the main repo
|
||||||
|
const { code: deleteBranchCode, stderr: deleteBranchError } =
|
||||||
|
await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
|
||||||
|
cwd: gitRoot,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (deleteBranchCode !== 0) {
|
||||||
|
logForDebugging(
|
||||||
|
`Could not delete agent worktree branch: ${deleteBranchError}`,
|
||||||
|
{ level: 'error' },
|
||||||
|
)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
})
|
||||||
|
|
||||||
// Delete the temporary worktree branch from the main repo
|
|
||||||
const { code: deleteBranchCode, stderr: deleteBranchError } =
|
|
||||||
await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
|
|
||||||
cwd: gitRoot,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (deleteBranchCode !== 0) {
|
|
||||||
logForDebugging(
|
|
||||||
`Could not delete agent worktree branch: ${deleteBranchError}`,
|
|
||||||
{ level: 'error' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "openclaude-vscode",
|
"name": "openclaude-vscode",
|
||||||
"displayName": "OpenClaude",
|
"displayName": "OpenClaude",
|
||||||
"description": "Practical VS Code companion for OpenClaude with project-aware launch behavior and a real Control Center.",
|
"description": "Practical VS Code companion for OpenClaude with project-aware launch behavior and a real Control Center.",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"publisher": "devnull-bootloader",
|
"publisher": "devnull-bootloader",
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.95.0"
|
"vscode": "^1.95.0"
|
||||||
@@ -19,7 +19,12 @@
|
|||||||
"onCommand:openclaude.openSetupDocs",
|
"onCommand:openclaude.openSetupDocs",
|
||||||
"onCommand:openclaude.openWorkspaceProfile",
|
"onCommand:openclaude.openWorkspaceProfile",
|
||||||
"onCommand:openclaude.openControlCenter",
|
"onCommand:openclaude.openControlCenter",
|
||||||
"onView:openclaude.controlCenter"
|
"onCommand:openclaude.newChat",
|
||||||
|
"onCommand:openclaude.openChat",
|
||||||
|
"onCommand:openclaude.resumeSession",
|
||||||
|
"onCommand:openclaude.abortChat",
|
||||||
|
"onView:openclaude.controlCenter",
|
||||||
|
"onView:openclaude.chat"
|
||||||
],
|
],
|
||||||
"main": "./src/extension.js",
|
"main": "./src/extension.js",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -28,6 +33,7 @@
|
|||||||
"src/extension.js",
|
"src/extension.js",
|
||||||
"src/presentation.js",
|
"src/presentation.js",
|
||||||
"src/state.js",
|
"src/state.js",
|
||||||
|
"src/chat/**",
|
||||||
"themes/**"
|
"themes/**"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
@@ -61,6 +67,26 @@
|
|||||||
"command": "openclaude.openControlCenter",
|
"command": "openclaude.openControlCenter",
|
||||||
"title": "OpenClaude: Open Control Center",
|
"title": "OpenClaude: Open Control Center",
|
||||||
"category": "OpenClaude"
|
"category": "OpenClaude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openclaude.newChat",
|
||||||
|
"title": "OpenClaude: New Chat",
|
||||||
|
"category": "OpenClaude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openclaude.openChat",
|
||||||
|
"title": "OpenClaude: Open Chat Panel",
|
||||||
|
"category": "OpenClaude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openclaude.resumeSession",
|
||||||
|
"title": "OpenClaude: Resume Session",
|
||||||
|
"category": "OpenClaude"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "openclaude.abortChat",
|
||||||
|
"title": "OpenClaude: Abort Generation",
|
||||||
|
"category": "OpenClaude"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"viewsContainers": {
|
"viewsContainers": {
|
||||||
@@ -74,6 +100,11 @@
|
|||||||
},
|
},
|
||||||
"views": {
|
"views": {
|
||||||
"openclaude": [
|
"openclaude": [
|
||||||
|
{
|
||||||
|
"id": "openclaude.chat",
|
||||||
|
"name": "Chat",
|
||||||
|
"type": "webview"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "openclaude.controlCenter",
|
"id": "openclaude.controlCenter",
|
||||||
"name": "Control Center",
|
"name": "Control Center",
|
||||||
@@ -81,6 +112,13 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"keybindings": [
|
||||||
|
{
|
||||||
|
"command": "openclaude.openChat",
|
||||||
|
"key": "ctrl+shift+l",
|
||||||
|
"mac": "cmd+shift+l"
|
||||||
|
}
|
||||||
|
],
|
||||||
"configuration": {
|
"configuration": {
|
||||||
"title": "OpenClaude",
|
"title": "OpenClaude",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -98,6 +136,18 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false,
|
"default": false,
|
||||||
"description": "Optionally set CLAUDE_CODE_USE_OPENAI=1 in launched OpenClaude terminals."
|
"description": "Optionally set CLAUDE_CODE_USE_OPENAI=1 in launched OpenClaude terminals."
|
||||||
|
},
|
||||||
|
"openclaude.permissionMode": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "acceptEdits",
|
||||||
|
"enum": ["default", "acceptEdits", "bypassPermissions", "plan"],
|
||||||
|
"enumDescriptions": [
|
||||||
|
"Prompt for permission on each tool use (requires manual approval)",
|
||||||
|
"Auto-approve file edits, prompt for other operations (recommended)",
|
||||||
|
"Auto-approve all operations without prompting",
|
||||||
|
"Read-only mode — no file modifications allowed"
|
||||||
|
],
|
||||||
|
"description": "Permission mode for chat sessions. Controls which tool operations are auto-approved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -111,7 +161,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test ./src/*.test.js",
|
"test": "node --test ./src/*.test.js",
|
||||||
"lint": "node -e \"for (const file of require('node:fs').readdirSync('./src')) { if (file.endsWith('.js')) { require('node:child_process').execFileSync(process.execPath, ['--check', require('node:path').join('src', file)], { stdio: 'inherit' }); } }\"",
|
"lint": "node scripts/lint.js",
|
||||||
"package": "npx @vscode/vsce package --no-dependencies"
|
"package": "npx @vscode/vsce package --no-dependencies"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
17
vscode-extension/openclaude-vscode/scripts/lint.js
Normal file
17
vscode-extension/openclaude-vscode/scripts/lint.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { readdirSync } = require('node:fs');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
function check(dir) {
|
||||||
|
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (f.isDirectory()) {
|
||||||
|
check(join(dir, f.name));
|
||||||
|
} else if (f.name.endsWith('.js') && !f.name.endsWith('.test.js')) {
|
||||||
|
execFileSync(process.execPath, ['--check', join(dir, f.name)], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check('./src');
|
||||||
676
vscode-extension/openclaude-vscode/src/chat/chatProvider.js
Normal file
676
vscode-extension/openclaude-vscode/src/chat/chatProvider.js
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
/**
|
||||||
|
* chatProvider — WebviewViewProvider (sidebar) and WebviewPanel manager
|
||||||
|
* (editor tab) that wire ProcessManager events to the chat UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const vscode = require('vscode');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { ProcessManager } = require('./processManager');
|
||||||
|
const { toViewModel } = require('./messageParser');
|
||||||
|
const { renderChatHtml } = require('./chatRenderer');
|
||||||
|
const { isAssistantMessage, isPartialMessage, isStreamEvent,
|
||||||
|
isContentBlockDelta, isContentBlockStart, isMessageStart,
|
||||||
|
isResultMessage, isControlRequest, isToolProgressMessage,
|
||||||
|
isStatusMessage, isRateLimitEvent, getTextContent,
|
||||||
|
getToolUseBlocks } = require('./protocol');
|
||||||
|
|
||||||
|
async function openFileInEditor(filePath) {
|
||||||
|
try {
|
||||||
|
const uri = vscode.Uri.file(filePath);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc, { preview: false });
|
||||||
|
} catch {
|
||||||
|
vscode.window.showWarningMessage(`Could not open file: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLaunchConfig() {
|
||||||
|
const cfg = vscode.workspace.getConfiguration('openclaude');
|
||||||
|
const command = cfg.get('launchCommand', 'openclaude');
|
||||||
|
const shimEnabled = cfg.get('useOpenAIShim', false);
|
||||||
|
const permissionMode = cfg.get('permissionMode', 'acceptEdits');
|
||||||
|
const env = {};
|
||||||
|
if (shimEnabled) env.CLAUDE_CODE_USE_OPENAI = '1';
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
const cwd = folders && folders.length > 0 ? folders[0].uri.fsPath : undefined;
|
||||||
|
return { command, cwd, env, permissionMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatController {
|
||||||
|
constructor(sessionManager) {
|
||||||
|
this._sessionManager = sessionManager;
|
||||||
|
this._process = null;
|
||||||
|
this._webviews = new Set();
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._toolUses = [];
|
||||||
|
this._messages = [];
|
||||||
|
this._currentSessionId = null;
|
||||||
|
this._streaming = false;
|
||||||
|
this._lastResult = null;
|
||||||
|
this._thinkingTokens = 0;
|
||||||
|
this._thinkingStartTime = null;
|
||||||
|
this._currentBlockType = null;
|
||||||
|
|
||||||
|
this._onDidChangeState = new vscode.EventEmitter();
|
||||||
|
this.onDidChangeState = this._onDidChangeState.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionId() { return this._currentSessionId; }
|
||||||
|
get isStreaming() { return this._process && this._process.running; }
|
||||||
|
get sessionManager() { return this._sessionManager; }
|
||||||
|
|
||||||
|
registerWebview(webview) {
|
||||||
|
this._webviews.add(webview);
|
||||||
|
return { dispose: () => this._webviews.delete(webview) };
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(msg) {
|
||||||
|
for (const wv of this._webviews) {
|
||||||
|
try { wv.postMessage(msg); } catch { /* webview might be disposed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcast(msg) {
|
||||||
|
this.broadcast(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(opts = {}) {
|
||||||
|
this.stopSession();
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._toolUses = [];
|
||||||
|
// Only clear messages if this is a brand new session (not continuing)
|
||||||
|
if (!opts.continueSession && !opts.sessionId) {
|
||||||
|
this._messages = [];
|
||||||
|
}
|
||||||
|
this._currentSessionId = opts.sessionId || this._currentSessionId || null;
|
||||||
|
|
||||||
|
const { command, cwd, env, permissionMode } = getLaunchConfig();
|
||||||
|
|
||||||
|
this._process = new ProcessManager({
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
sessionId: opts.sessionId,
|
||||||
|
continueSession: opts.continueSession || false,
|
||||||
|
model: opts.model,
|
||||||
|
permissionMode,
|
||||||
|
extraArgs: opts.extraArgs || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
this._readyResolve = null;
|
||||||
|
this._readyPromise = new Promise(resolve => { this._readyResolve = resolve; });
|
||||||
|
|
||||||
|
this._process.onMessage((msg) => {
|
||||||
|
if (msg.type === 'system' && this._readyResolve) {
|
||||||
|
this._readyResolve();
|
||||||
|
this._readyResolve = null;
|
||||||
|
}
|
||||||
|
this._handleMessage(msg);
|
||||||
|
});
|
||||||
|
this._process.onError((err) => {
|
||||||
|
this._broadcast({ type: 'error', message: err.message || String(err) });
|
||||||
|
});
|
||||||
|
this._process.onExit(({ code }) => {
|
||||||
|
// Flush any remaining streamed text
|
||||||
|
if (this._streaming && this._accumulatedText) {
|
||||||
|
this._broadcast({ type: 'stream_end', text: this._accumulatedText, usage: null, final: true });
|
||||||
|
} else if (this._streaming) {
|
||||||
|
this._broadcast({ type: 'stream_end', text: '', usage: (this._lastResult || {}).usage || null, final: true });
|
||||||
|
}
|
||||||
|
this._streaming = false;
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._toolUses = [];
|
||||||
|
this._lastResult = null;
|
||||||
|
this._broadcast({
|
||||||
|
type: 'connected',
|
||||||
|
message: code === 0 ? 'Ready' : `Process exited (code ${code})`,
|
||||||
|
});
|
||||||
|
this._onDidChangeState.fire('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._process.start();
|
||||||
|
this._broadcast({ type: 'connected', message: 'Connected' });
|
||||||
|
this._onDidChangeState.fire('connected');
|
||||||
|
} catch (err) {
|
||||||
|
this._broadcast({ type: 'error', message: `Failed to start: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSession() {
|
||||||
|
if (this._process) {
|
||||||
|
this._process.dispose();
|
||||||
|
this._process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(text) {
|
||||||
|
// Keep the process alive for multi-turn — just send directly.
|
||||||
|
// The CLI maintains full session state (tools, history) across turns.
|
||||||
|
// Only start a new process if none exists or it died.
|
||||||
|
if (!this._process || !this._process.running) {
|
||||||
|
await this.startSession({
|
||||||
|
sessionId: this._currentSessionId || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this._doSend(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doSend(text) {
|
||||||
|
if (!this._process) return;
|
||||||
|
// On first message after process start, wait for CLI to be ready.
|
||||||
|
// On subsequent messages, the process is already running and accepting input.
|
||||||
|
if (this._readyPromise) {
|
||||||
|
const grace = new Promise(resolve => setTimeout(resolve, 8000));
|
||||||
|
await Promise.race([this._readyPromise, grace]);
|
||||||
|
this._readyPromise = null;
|
||||||
|
}
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._toolUses = [];
|
||||||
|
try {
|
||||||
|
this._process.sendUserMessage(text);
|
||||||
|
this._messages.push({ role: 'user', text });
|
||||||
|
} catch (err) {
|
||||||
|
this._broadcast({ type: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
if (this._process) {
|
||||||
|
this._process.abort();
|
||||||
|
this._broadcast({ type: 'stream_end', text: this._accumulatedText, usage: null });
|
||||||
|
this._onDidChangeState.fire('idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPermissionResponse(requestId, action, toolUseId) {
|
||||||
|
if (!this._process) return;
|
||||||
|
if (action === 'deny') {
|
||||||
|
try {
|
||||||
|
this._process.write({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId,
|
||||||
|
error: 'User denied permission',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this._broadcast({ type: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this._process.sendControlResponse(requestId, {
|
||||||
|
toolUseID: toolUseId || undefined,
|
||||||
|
...(action === 'allow-session' ? { remember: true } : {}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this._broadcast({ type: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages() { return this._messages; }
|
||||||
|
|
||||||
|
_handleMessage(msg) {
|
||||||
|
if (msg.session_id && !this._currentSessionId) {
|
||||||
|
this._currentSessionId = msg.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System message — extract model and session info
|
||||||
|
if (msg.type === 'system') {
|
||||||
|
this._broadcast({
|
||||||
|
type: 'system_info',
|
||||||
|
model: msg.model || null,
|
||||||
|
sessionId: msg.session_id || msg.sessionId || null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control request (permission prompt) — check EARLY before other handlers
|
||||||
|
if (msg.type === 'control_request' || isControlRequest(msg)) {
|
||||||
|
const req = msg.request || {};
|
||||||
|
const { toolDisplayName, parseToolInput } = require('./messageParser');
|
||||||
|
this._broadcast({
|
||||||
|
type: 'permission_request',
|
||||||
|
requestId: msg.request_id,
|
||||||
|
toolName: req.tool_name || 'Unknown',
|
||||||
|
displayName: req.display_name || req.title || toolDisplayName(req.tool_name),
|
||||||
|
description: req.description || '',
|
||||||
|
inputPreview: parseToolInput(req.input),
|
||||||
|
toolUseId: req.tool_use_id || null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control cancel request
|
||||||
|
if (msg.type === 'control_cancel_request') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Anthropic raw stream events (the primary streaming mechanism)
|
||||||
|
if (isStreamEvent(msg)) {
|
||||||
|
this._handleStreamEvent(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant message — always mid-turn; true completion comes from 'result'
|
||||||
|
if (isAssistantMessage(msg)) {
|
||||||
|
const inner = msg.message || msg;
|
||||||
|
const text = getTextContent(inner);
|
||||||
|
const toolBlocks = getToolUseBlocks(inner);
|
||||||
|
const { toolDisplayName, toolIcon } = require('./messageParser');
|
||||||
|
const toolUseVms = toolBlocks.map(tu => ({
|
||||||
|
id: tu.id,
|
||||||
|
name: tu.name,
|
||||||
|
displayName: toolDisplayName(tu.name),
|
||||||
|
icon: toolIcon(tu.name),
|
||||||
|
inputPreview: typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input || ''),
|
||||||
|
input: tu.input,
|
||||||
|
status: 'running',
|
||||||
|
}));
|
||||||
|
this._messages.push({ role: 'assistant', text, toolUses: toolUseVms });
|
||||||
|
const usage = inner.usage || msg.usage || null;
|
||||||
|
|
||||||
|
// Finalize current text bubble but stay streaming — true completion
|
||||||
|
// is signaled by the 'result' message, not by the assistant message.
|
||||||
|
this._broadcast({ type: 'stream_end', text, usage, final: false });
|
||||||
|
this._accumulatedText = '';
|
||||||
|
|
||||||
|
if (toolBlocks.length > 0) {
|
||||||
|
for (const tu of toolBlocks) {
|
||||||
|
this._broadcast({
|
||||||
|
type: 'tool_input_ready',
|
||||||
|
toolUseId: tu.id,
|
||||||
|
input: tu.input,
|
||||||
|
name: tu.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._broadcast({ type: 'status', content: 'Using tools...' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User message with tool_use_result — this is the tool output
|
||||||
|
if (msg.type === 'user' && msg.message) {
|
||||||
|
const content = msg.message.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||||
|
const resultText = typeof block.content === 'string'
|
||||||
|
? block.content
|
||||||
|
: Array.isArray(block.content)
|
||||||
|
? block.content.map(b => b.text || '').join('')
|
||||||
|
: '';
|
||||||
|
this._broadcast({
|
||||||
|
type: 'tool_result',
|
||||||
|
toolUseId: block.tool_use_id,
|
||||||
|
content: resultText.slice(0, 2000) || '(done)',
|
||||||
|
isError: block.is_error || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._broadcast({ type: 'status', content: 'Thinking...' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session result — turn is complete. Go idle. The process stays alive
|
||||||
|
// in stream-json mode for multi-turn conversation.
|
||||||
|
if (msg.type === 'result' && msg.subtype) {
|
||||||
|
this._lastResult = msg;
|
||||||
|
// Only use result text if nothing was shown via streaming yet
|
||||||
|
const text = this._accumulatedText || '';
|
||||||
|
this._broadcast({ type: 'stream_end', text, usage: msg.usage || null, final: true });
|
||||||
|
// Show turn info: if the model stopped without using tools (num_turns=1),
|
||||||
|
// the user knows the model chose not to edit
|
||||||
|
if (msg.num_turns !== undefined) {
|
||||||
|
const reason = msg.stop_reason || 'done';
|
||||||
|
this._broadcast({
|
||||||
|
type: 'status',
|
||||||
|
content: msg.num_turns > 1
|
||||||
|
? 'Completed (' + msg.num_turns + ' turns)'
|
||||||
|
: 'Ready',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._toolUses = [];
|
||||||
|
this._streaming = false;
|
||||||
|
this._onDidChangeState.fire('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolProgressMessage(msg)) {
|
||||||
|
const vm = toViewModel(msg)[0];
|
||||||
|
this._broadcast({
|
||||||
|
type: 'tool_progress',
|
||||||
|
toolUseId: vm.toolUseId,
|
||||||
|
content: vm.content,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStatusMessage(msg)) {
|
||||||
|
const vm = toViewModel(msg)[0];
|
||||||
|
this._broadcast({ type: 'status', content: vm.content });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimitEvent(msg)) {
|
||||||
|
const vm = toViewModel(msg)[0];
|
||||||
|
this._broadcast({ type: 'rate_limit', message: vm.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log unhandled message types for debugging
|
||||||
|
if (msg.type && msg.type !== 'stream_event') {
|
||||||
|
this._broadcast({ type: 'status', content: '[debug] unhandled: ' + msg.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleStreamEvent(msg) {
|
||||||
|
const event = msg.event;
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'message_start':
|
||||||
|
this._accumulatedText = '';
|
||||||
|
this._thinkingTokens = 0;
|
||||||
|
this._currentBlockType = null;
|
||||||
|
if (!this._streaming) {
|
||||||
|
this._streaming = true;
|
||||||
|
this._toolUses = [];
|
||||||
|
this._onDidChangeState.fire('streaming');
|
||||||
|
}
|
||||||
|
this._broadcast({ type: 'stream_start' });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'content_block_start':
|
||||||
|
if (event.content_block) {
|
||||||
|
this._currentBlockType = event.content_block.type;
|
||||||
|
if (event.content_block.type === 'tool_use') {
|
||||||
|
const tu = event.content_block;
|
||||||
|
this._toolUses.push({ id: tu.id, name: tu.name, input: '' });
|
||||||
|
const { toolDisplayName, toolIcon } = require('./messageParser');
|
||||||
|
this._broadcast({
|
||||||
|
type: 'tool_use',
|
||||||
|
toolUse: {
|
||||||
|
id: tu.id,
|
||||||
|
name: tu.name,
|
||||||
|
displayName: toolDisplayName(tu.name),
|
||||||
|
icon: toolIcon(tu.name),
|
||||||
|
inputPreview: '',
|
||||||
|
input: tu.input || null,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (event.content_block.type === 'thinking') {
|
||||||
|
this._thinkingTokens = 0;
|
||||||
|
this._thinkingStartTime = Date.now();
|
||||||
|
this._broadcast({ type: 'thinking_start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'content_block_delta':
|
||||||
|
if (event.delta) {
|
||||||
|
if (event.delta.type === 'text_delta' && event.delta.text) {
|
||||||
|
this._accumulatedText += event.delta.text;
|
||||||
|
this._broadcast({ type: 'stream_delta', text: this._accumulatedText });
|
||||||
|
} else if (event.delta.type === 'thinking_delta') {
|
||||||
|
this._thinkingTokens += (event.delta.thinking || '').length;
|
||||||
|
const elapsed = Math.round((Date.now() - (this._thinkingStartTime || Date.now())) / 1000);
|
||||||
|
this._broadcast({
|
||||||
|
type: 'thinking_delta',
|
||||||
|
tokens: this._thinkingTokens,
|
||||||
|
elapsed,
|
||||||
|
});
|
||||||
|
} else if (event.delta.type === 'input_json_delta' && event.delta.partial_json) {
|
||||||
|
const lastTool = this._toolUses[this._toolUses.length - 1];
|
||||||
|
if (lastTool) {
|
||||||
|
lastTool.input = (lastTool.input || '') + event.delta.partial_json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'content_block_stop':
|
||||||
|
if (this._currentBlockType === 'thinking') {
|
||||||
|
this._broadcast({ type: 'thinking_end' });
|
||||||
|
}
|
||||||
|
this._currentBlockType = null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_delta':
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'message_stop':
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.stopSession();
|
||||||
|
this._onDidChangeState.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenClaudeChatViewProvider {
|
||||||
|
constructor(chatController) {
|
||||||
|
this._chatController = chatController;
|
||||||
|
this._webviewView = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveWebviewView(webviewView, _context, _token) {
|
||||||
|
this._webviewView = webviewView;
|
||||||
|
const webview = webviewView.webview;
|
||||||
|
webview.options = { enableScripts: true };
|
||||||
|
|
||||||
|
const registration = this._chatController.registerWebview(webview);
|
||||||
|
webviewView.onDidDispose(() => {
|
||||||
|
registration.dispose();
|
||||||
|
if (this._webviewView === webviewView) this._webviewView = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.html = this._getHtml(webview);
|
||||||
|
this._attachMessageHandler(webview);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getHtml() {
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex');
|
||||||
|
return renderChatHtml({ nonce, platform: process.platform });
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachMessageHandler(webview) {
|
||||||
|
webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'send_message':
|
||||||
|
this._chatController.sendMessage(msg.text);
|
||||||
|
break;
|
||||||
|
case 'abort':
|
||||||
|
this._chatController.abort();
|
||||||
|
break;
|
||||||
|
case 'new_session':
|
||||||
|
this._chatController.stopSession();
|
||||||
|
webview.postMessage({ type: 'session_cleared' });
|
||||||
|
break;
|
||||||
|
case 'resume_session':
|
||||||
|
this._chatController.stopSession();
|
||||||
|
webview.postMessage({ type: 'session_cleared' });
|
||||||
|
await this._loadAndDisplaySession(webview, msg.sessionId);
|
||||||
|
await this._chatController.startSession({ sessionId: msg.sessionId });
|
||||||
|
break;
|
||||||
|
case 'permission_response':
|
||||||
|
this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId);
|
||||||
|
break;
|
||||||
|
case 'copy_code':
|
||||||
|
if (msg.text) await vscode.env.clipboard.writeText(msg.text);
|
||||||
|
break;
|
||||||
|
case 'open_file':
|
||||||
|
if (msg.path) await openFileInEditor(msg.path);
|
||||||
|
break;
|
||||||
|
case 'request_sessions':
|
||||||
|
await this._sendSessionList(webview);
|
||||||
|
break;
|
||||||
|
case 'restore_request':
|
||||||
|
this._restoreMessages(webview);
|
||||||
|
break;
|
||||||
|
case 'webview_ready':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendSessionList(webview) {
|
||||||
|
if (!this._chatController.sessionManager) return;
|
||||||
|
try {
|
||||||
|
const sessions = await this._chatController.sessionManager.listSessions();
|
||||||
|
webview.postMessage({ type: 'session_list', sessions });
|
||||||
|
} catch {
|
||||||
|
webview.postMessage({ type: 'session_list', sessions: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreMessages(webview) {
|
||||||
|
const messages = this._chatController.getMessages();
|
||||||
|
if (messages.length > 0) {
|
||||||
|
webview.postMessage({ type: 'restore_messages', messages });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadAndDisplaySession(webview, sessionId) {
|
||||||
|
if (!this._chatController.sessionManager) return;
|
||||||
|
try {
|
||||||
|
const messages = await this._chatController.sessionManager.loadSession(sessionId);
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
this._chatController._messages = messages;
|
||||||
|
webview.postMessage({ type: 'restore_messages', messages });
|
||||||
|
}
|
||||||
|
} catch { /* session may not be loadable */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenClaudeChatPanelManager {
|
||||||
|
constructor(chatController) {
|
||||||
|
this._chatController = chatController;
|
||||||
|
this._panel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openPanel() {
|
||||||
|
if (this._panel) {
|
||||||
|
this._panel.reveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._panel = vscode.window.createWebviewPanel(
|
||||||
|
'openclaude.chatPanel',
|
||||||
|
'OpenClaude Chat',
|
||||||
|
vscode.ViewColumn.Beside,
|
||||||
|
{
|
||||||
|
enableScripts: true,
|
||||||
|
retainContextWhenHidden: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const webview = this._panel.webview;
|
||||||
|
const registration = this._chatController.registerWebview(webview);
|
||||||
|
|
||||||
|
this._panel.onDidDispose(() => {
|
||||||
|
registration.dispose();
|
||||||
|
this._panel = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex');
|
||||||
|
webview.html = renderChatHtml({ nonce, platform: process.platform });
|
||||||
|
this._attachMessageHandler(webview);
|
||||||
|
|
||||||
|
const messages = this._chatController.getMessages();
|
||||||
|
if (messages.length > 0) {
|
||||||
|
webview.postMessage({ type: 'restore_messages', messages });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_attachMessageHandler(webview) {
|
||||||
|
webview.onDidReceiveMessage(async (msg) => {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'send_message':
|
||||||
|
this._chatController.sendMessage(msg.text);
|
||||||
|
break;
|
||||||
|
case 'abort':
|
||||||
|
this._chatController.abort();
|
||||||
|
break;
|
||||||
|
case 'new_session':
|
||||||
|
this._chatController.stopSession();
|
||||||
|
webview.postMessage({ type: 'session_cleared' });
|
||||||
|
break;
|
||||||
|
case 'resume_session':
|
||||||
|
this._chatController.stopSession();
|
||||||
|
webview.postMessage({ type: 'session_cleared' });
|
||||||
|
await this._loadAndDisplaySession(webview, msg.sessionId);
|
||||||
|
await this._chatController.startSession({ sessionId: msg.sessionId });
|
||||||
|
break;
|
||||||
|
case 'permission_response':
|
||||||
|
this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId);
|
||||||
|
break;
|
||||||
|
case 'copy_code':
|
||||||
|
if (msg.text) await vscode.env.clipboard.writeText(msg.text);
|
||||||
|
break;
|
||||||
|
case 'open_file':
|
||||||
|
if (msg.path) await openFileInEditor(msg.path);
|
||||||
|
break;
|
||||||
|
case 'request_sessions':
|
||||||
|
await this._sendSessionList(webview);
|
||||||
|
break;
|
||||||
|
case 'restore_request':
|
||||||
|
this._restoreMessages(webview);
|
||||||
|
break;
|
||||||
|
case 'webview_ready':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _sendSessionList(webview) {
|
||||||
|
if (!this._chatController.sessionManager) return;
|
||||||
|
try {
|
||||||
|
const sessions = await this._chatController.sessionManager.listSessions();
|
||||||
|
webview.postMessage({ type: 'session_list', sessions });
|
||||||
|
} catch {
|
||||||
|
webview.postMessage({ type: 'session_list', sessions: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreMessages(webview) {
|
||||||
|
const messages = this._chatController.getMessages();
|
||||||
|
if (messages.length > 0) {
|
||||||
|
webview.postMessage({ type: 'restore_messages', messages });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadAndDisplaySession(webview, sessionId) {
|
||||||
|
if (!this._chatController.sessionManager) return;
|
||||||
|
try {
|
||||||
|
const messages = await this._chatController.sessionManager.loadSession(sessionId);
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
this._chatController._messages = messages;
|
||||||
|
webview.postMessage({ type: 'restore_messages', messages });
|
||||||
|
}
|
||||||
|
} catch { /* session may not be loadable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._panel) {
|
||||||
|
this._panel.dispose();
|
||||||
|
this._panel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ChatController,
|
||||||
|
OpenClaudeChatViewProvider,
|
||||||
|
OpenClaudeChatPanelManager,
|
||||||
|
};
|
||||||
1354
vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
Normal file
1354
vscode-extension/openclaude-vscode/src/chat/chatRenderer.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* diffController — provides a TextDocumentContentProvider for virtual
|
||||||
|
* diff documents and helpers to open VS Code's native diff editor when
|
||||||
|
* tool use involves file edits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const vscode = require('vscode');
|
||||||
|
|
||||||
|
const SCHEME = 'openclaude-diff';
|
||||||
|
let contentStore = new Map();
|
||||||
|
|
||||||
|
class DiffContentProvider {
|
||||||
|
constructor() {
|
||||||
|
this._onDidChange = new vscode.EventEmitter();
|
||||||
|
this.onDidChange = this._onDidChange.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
provideTextDocumentContent(uri) {
|
||||||
|
return contentStore.get(uri.toString()) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
update(uri) {
|
||||||
|
this._onDidChange.fire(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._onDidChange.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeContent(id, content) {
|
||||||
|
const uri = vscode.Uri.parse(`${SCHEME}:/${id}`);
|
||||||
|
contentStore.set(uri.toString(), content);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContent(id) {
|
||||||
|
const uri = vscode.Uri.parse(`${SCHEME}:/${id}`);
|
||||||
|
contentStore.delete(uri.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
contentStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a diff view between original and modified content.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.filePath - Display path (for the title)
|
||||||
|
* @param {string} opts.original - Original file content
|
||||||
|
* @param {string} opts.modified - Modified file content
|
||||||
|
* @param {string} [opts.toolUseId] - Unique ID for this diff
|
||||||
|
*/
|
||||||
|
async function openDiff({ filePath, original, modified, toolUseId }) {
|
||||||
|
const id = toolUseId || Math.random().toString(36).slice(2, 10);
|
||||||
|
const originalUri = storeContent(`original-${id}`, original || '');
|
||||||
|
const modifiedUri = storeContent(`modified-${id}`, modified || '');
|
||||||
|
const shortName = filePath ? filePath.split(/[\\/]/).pop() : 'file';
|
||||||
|
const title = `${shortName} (OpenClaude Diff)`;
|
||||||
|
|
||||||
|
await vscode.commands.executeCommand('vscode.diff', originalUri, modifiedUri, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a diff between a real file on disk and modified content from
|
||||||
|
* a tool use result.
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.filePath - Absolute path to the real file
|
||||||
|
* @param {string} opts.modified - Modified content
|
||||||
|
* @param {string} [opts.toolUseId]
|
||||||
|
*/
|
||||||
|
async function openFileDiff({ filePath, modified, toolUseId }) {
|
||||||
|
const id = toolUseId || Math.random().toString(36).slice(2, 10);
|
||||||
|
const fileUri = vscode.Uri.file(filePath);
|
||||||
|
const modifiedUri = storeContent(`modified-${id}`, modified || '');
|
||||||
|
const shortName = filePath.split(/[\\/]/).pop() || 'file';
|
||||||
|
const title = `${shortName} (OpenClaude Edit)`;
|
||||||
|
|
||||||
|
await vscode.commands.executeCommand('vscode.diff', fileUri, modifiedUri, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DiffContentProvider,
|
||||||
|
SCHEME,
|
||||||
|
openDiff,
|
||||||
|
openFileDiff,
|
||||||
|
storeContent,
|
||||||
|
clearContent,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
177
vscode-extension/openclaude-vscode/src/chat/messageParser.js
Normal file
177
vscode-extension/openclaude-vscode/src/chat/messageParser.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* messageParser — transforms raw SDK messages from the CLI into view-model
|
||||||
|
* objects that the chat renderer can display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAssistantMessage,
|
||||||
|
isPartialMessage,
|
||||||
|
isResultMessage,
|
||||||
|
isControlRequest,
|
||||||
|
isStatusMessage,
|
||||||
|
isToolProgressMessage,
|
||||||
|
isSessionStateChanged,
|
||||||
|
isRateLimitEvent,
|
||||||
|
getTextContent,
|
||||||
|
getToolUseBlocks,
|
||||||
|
} = require('./protocol');
|
||||||
|
|
||||||
|
function parseToolInput(input) {
|
||||||
|
if (!input || typeof input !== 'object') return String(input ?? '');
|
||||||
|
if (input.command) return input.command;
|
||||||
|
if (input.file_path || input.path) return input.file_path || input.path;
|
||||||
|
if (input.query) return input.query;
|
||||||
|
try { return JSON.stringify(input, null, 2); } catch { return String(input); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolDisplayName(name) {
|
||||||
|
const map = {
|
||||||
|
Bash: 'Terminal',
|
||||||
|
Read: 'Read File',
|
||||||
|
Write: 'Write File',
|
||||||
|
Edit: 'Edit File',
|
||||||
|
MultiEdit: 'Multi Edit',
|
||||||
|
Glob: 'Find Files',
|
||||||
|
Grep: 'Search',
|
||||||
|
LS: 'List Directory',
|
||||||
|
WebFetch: 'Web Fetch',
|
||||||
|
WebSearch: 'Web Search',
|
||||||
|
TodoRead: 'Read Todos',
|
||||||
|
TodoWrite: 'Write Todos',
|
||||||
|
Task: 'Sub-agent',
|
||||||
|
};
|
||||||
|
return map[name] || name || 'Tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolIcon(name) {
|
||||||
|
const map = {
|
||||||
|
Bash: '\u{1F4BB}',
|
||||||
|
Read: '\u{1F4C4}',
|
||||||
|
Write: '\u{270F}\uFE0F',
|
||||||
|
Edit: '\u{270F}\uFE0F',
|
||||||
|
MultiEdit: '\u{270F}\uFE0F',
|
||||||
|
Glob: '\u{1F50D}',
|
||||||
|
Grep: '\u{1F50E}',
|
||||||
|
LS: '\u{1F4C2}',
|
||||||
|
WebFetch: '\u{1F310}',
|
||||||
|
WebSearch: '\u{1F310}',
|
||||||
|
Task: '\u{1F916}',
|
||||||
|
};
|
||||||
|
return map[name] || '\u{1F527}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an SDK message into one or more view-model entries for the chat UI.
|
||||||
|
* Returns an array so partial messages can update in-place while final messages
|
||||||
|
* produce a finalized entry.
|
||||||
|
*/
|
||||||
|
function toViewModel(msg) {
|
||||||
|
if (isAssistantMessage(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'assistant',
|
||||||
|
id: msg.id || msg.message?.id || null,
|
||||||
|
text: getTextContent(msg.message || msg),
|
||||||
|
toolUses: getToolUseBlocks(msg.message || msg).map(tu => ({
|
||||||
|
id: tu.id,
|
||||||
|
name: tu.name,
|
||||||
|
displayName: toolDisplayName(tu.name),
|
||||||
|
icon: toolIcon(tu.name),
|
||||||
|
inputPreview: parseToolInput(tu.input),
|
||||||
|
input: tu.input,
|
||||||
|
status: 'complete',
|
||||||
|
})),
|
||||||
|
model: msg.model || null,
|
||||||
|
stopReason: msg.stop_reason || null,
|
||||||
|
usage: msg.usage || null,
|
||||||
|
final: true,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPartialMessage(msg)) {
|
||||||
|
const inner = msg.message || msg;
|
||||||
|
return [{
|
||||||
|
kind: 'assistant_partial',
|
||||||
|
id: inner.id || null,
|
||||||
|
text: getTextContent(inner),
|
||||||
|
toolUses: getToolUseBlocks(inner).map(tu => ({
|
||||||
|
id: tu.id,
|
||||||
|
name: tu.name,
|
||||||
|
displayName: toolDisplayName(tu.name),
|
||||||
|
icon: toolIcon(tu.name),
|
||||||
|
inputPreview: parseToolInput(tu.input),
|
||||||
|
input: tu.input,
|
||||||
|
status: 'running',
|
||||||
|
})),
|
||||||
|
final: false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResultMessage(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'tool_result',
|
||||||
|
toolUseId: msg.tool_use_id,
|
||||||
|
content: typeof msg.content === 'string'
|
||||||
|
? msg.content
|
||||||
|
: Array.isArray(msg.content)
|
||||||
|
? msg.content.map(b => b.text || '').join('')
|
||||||
|
: '',
|
||||||
|
isError: msg.is_error || false,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isControlRequest(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'permission_request',
|
||||||
|
requestId: msg.request_id || msg.id,
|
||||||
|
toolName: msg.tool_name || msg.tool?.name || 'Unknown',
|
||||||
|
displayName: toolDisplayName(msg.tool_name || msg.tool?.name),
|
||||||
|
description: msg.description || msg.tool?.description || '',
|
||||||
|
input: msg.tool_input || msg.input || null,
|
||||||
|
inputPreview: parseToolInput(msg.tool_input || msg.input),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToolProgressMessage(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'tool_progress',
|
||||||
|
toolUseId: msg.tool_use_id,
|
||||||
|
content: msg.content || msg.progress || '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStatusMessage(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'status',
|
||||||
|
content: msg.content || msg.message || '',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSessionStateChanged(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'session_state',
|
||||||
|
sessionId: msg.session_id || null,
|
||||||
|
state: msg.state || null,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRateLimitEvent(msg)) {
|
||||||
|
return [{
|
||||||
|
kind: 'rate_limit',
|
||||||
|
retryAfter: msg.retry_after || null,
|
||||||
|
message: msg.message || 'Rate limited',
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind: 'unknown',
|
||||||
|
type: msg.type,
|
||||||
|
raw: msg,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
toViewModel,
|
||||||
|
toolDisplayName,
|
||||||
|
toolIcon,
|
||||||
|
parseToolInput,
|
||||||
|
};
|
||||||
194
vscode-extension/openclaude-vscode/src/chat/processManager.js
Normal file
194
vscode-extension/openclaude-vscode/src/chat/processManager.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* ProcessManager — spawns OpenClaude in print/SDK mode and manages the
|
||||||
|
* NDJSON stdin/stdout lifecycle.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const pm = new ProcessManager({ command, cwd, env });
|
||||||
|
* pm.onMessage(msg => { ... });
|
||||||
|
* pm.onError(err => { ... });
|
||||||
|
* pm.onExit(code => { ... });
|
||||||
|
* await pm.start();
|
||||||
|
* pm.sendUserMessage('Hello');
|
||||||
|
* pm.abort(); // SIGINT (graceful)
|
||||||
|
* pm.kill(); // SIGTERM (hard)
|
||||||
|
* pm.dispose();
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const vscode = require('vscode');
|
||||||
|
const { parseStdoutLine, serializeStdinMessage, buildUserMessage, buildControlResponse } = require('./protocol');
|
||||||
|
|
||||||
|
class ProcessManager {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.command - The openclaude binary (e.g. 'openclaude')
|
||||||
|
* @param {string} [opts.cwd] - Working directory
|
||||||
|
* @param {Record<string,string>} [opts.env] - Extra env vars
|
||||||
|
* @param {string} [opts.sessionId] - Session to resume
|
||||||
|
* @param {boolean} [opts.continueSession] - Use --continue instead of --resume
|
||||||
|
* @param {string} [opts.model] - Model override
|
||||||
|
* @param {string[]} [opts.extraArgs] - Additional CLI flags
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this._command = opts.command || 'openclaude';
|
||||||
|
this._cwd = opts.cwd || undefined;
|
||||||
|
this._env = opts.env || {};
|
||||||
|
this._sessionId = opts.sessionId || null;
|
||||||
|
this._continueSession = opts.continueSession || false;
|
||||||
|
this._model = opts.model || null;
|
||||||
|
this._permissionMode = opts.permissionMode || 'acceptEdits';
|
||||||
|
this._extraArgs = opts.extraArgs || [];
|
||||||
|
this._process = null;
|
||||||
|
this._buffer = '';
|
||||||
|
this._disposed = false;
|
||||||
|
|
||||||
|
this._onMessageEmitter = new vscode.EventEmitter();
|
||||||
|
this._onErrorEmitter = new vscode.EventEmitter();
|
||||||
|
this._onExitEmitter = new vscode.EventEmitter();
|
||||||
|
this.onMessage = this._onMessageEmitter.event;
|
||||||
|
this.onError = this._onErrorEmitter.event;
|
||||||
|
this.onExit = this._onExitEmitter.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
get running() {
|
||||||
|
return this._process !== null && !this._process.killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionId() {
|
||||||
|
return this._sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this._disposed) throw new Error('ProcessManager is disposed');
|
||||||
|
if (this._process) throw new Error('Process already started');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--print',
|
||||||
|
'--verbose',
|
||||||
|
'--input-format=stream-json',
|
||||||
|
'--output-format=stream-json',
|
||||||
|
'--include-partial-messages',
|
||||||
|
'--permission-mode', this._permissionMode || 'acceptEdits',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this._sessionId) {
|
||||||
|
args.push('--resume', this._sessionId);
|
||||||
|
} else if (this._continueSession) {
|
||||||
|
args.push('--continue');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._model) {
|
||||||
|
args.push('--model', this._model);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(...this._extraArgs);
|
||||||
|
|
||||||
|
const spawnEnv = { ...process.env, ...this._env };
|
||||||
|
const isWin = process.platform === 'win32';
|
||||||
|
|
||||||
|
if (isWin) {
|
||||||
|
// On Windows, npm global installs create .cmd shims that spawn()
|
||||||
|
// cannot find without a shell. Build one command string so the
|
||||||
|
// deprecation warning about unsanitised args does not fire.
|
||||||
|
const cmdLine = [this._command, ...args].join(' ');
|
||||||
|
this._process = spawn(cmdLine, [], {
|
||||||
|
cwd: this._cwd,
|
||||||
|
env: spawnEnv,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: true,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._process = spawn(this._command, args, {
|
||||||
|
cwd: this._cwd,
|
||||||
|
env: spawnEnv,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._process.stdout.setEncoding('utf8');
|
||||||
|
this._process.stderr.setEncoding('utf8');
|
||||||
|
|
||||||
|
this._process.stdout.on('data', (chunk) => this._onData(chunk));
|
||||||
|
this._process.stderr.on('data', (chunk) => this._onStderr(chunk));
|
||||||
|
this._process.on('error', (err) => this._onErrorEmitter.fire(err));
|
||||||
|
this._process.on('close', (code, signal) => {
|
||||||
|
this._process = null;
|
||||||
|
this._onExitEmitter.fire({ code, signal });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onData(chunk) {
|
||||||
|
this._buffer += chunk;
|
||||||
|
const lines = this._buffer.split('\n');
|
||||||
|
this._buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const msg = parseStdoutLine(line);
|
||||||
|
if (msg) {
|
||||||
|
this._extractSessionId(msg);
|
||||||
|
this._onMessageEmitter.fire(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_extractSessionId(msg) {
|
||||||
|
if (msg.session_id && !this._sessionId) {
|
||||||
|
this._sessionId = msg.session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onStderr(chunk) {
|
||||||
|
const trimmed = chunk.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
// Suppress common non-error noise from the CLI (deprecation warnings, etc.)
|
||||||
|
if (/^\(node:\d+\)|^DeprecationWarning|^ExperimentalWarning/i.test(trimmed)) return;
|
||||||
|
this._onErrorEmitter.fire(new Error(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUserMessage(text) {
|
||||||
|
this._write(buildUserMessage(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendControlResponse(requestId, result) {
|
||||||
|
this._write(buildControlResponse(requestId, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
write(msg) {
|
||||||
|
if (!this._process || !this._process.stdin.writable) {
|
||||||
|
throw new Error('Process is not running');
|
||||||
|
}
|
||||||
|
this._process.stdin.write(serializeStdinMessage(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
_write(msg) {
|
||||||
|
this.write(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
if (this._process && !this._process.killed) {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
this._process.kill('SIGINT');
|
||||||
|
} else {
|
||||||
|
this._process.kill('SIGINT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kill() {
|
||||||
|
if (this._process && !this._process.killed) {
|
||||||
|
this._process.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._disposed = true;
|
||||||
|
this.kill();
|
||||||
|
this._onMessageEmitter.dispose();
|
||||||
|
this._onErrorEmitter.dispose();
|
||||||
|
this._onExitEmitter.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ProcessManager };
|
||||||
186
vscode-extension/openclaude-vscode/src/chat/protocol.js
Normal file
186
vscode-extension/openclaude-vscode/src/chat/protocol.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* NDJSON protocol helpers and message type constants for the OpenClaude
|
||||||
|
* stream-json SDK wire format.
|
||||||
|
*
|
||||||
|
* The extension spawns `openclaude --print --input-format=stream-json
|
||||||
|
* --output-format=stream-json` and speaks NDJSON over stdin/stdout.
|
||||||
|
* This module provides lightweight parsing, serialization, and type guards
|
||||||
|
* so the rest of the extension never touches raw JSON strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MESSAGE_TYPES = {
|
||||||
|
ASSISTANT: 'assistant',
|
||||||
|
USER: 'user',
|
||||||
|
USER_REPLAY: 'user_replay',
|
||||||
|
RESULT: 'result',
|
||||||
|
SYSTEM: 'system',
|
||||||
|
STREAM_EVENT: 'stream_event',
|
||||||
|
PARTIAL: 'partial',
|
||||||
|
COMPACT_BOUNDARY: 'compact_boundary',
|
||||||
|
STATUS: 'status',
|
||||||
|
API_RETRY: 'api_retry',
|
||||||
|
LOCAL_COMMAND_OUTPUT: 'local_command_output',
|
||||||
|
HOOK_STARTED: 'hook_started',
|
||||||
|
HOOK_PROGRESS: 'hook_progress',
|
||||||
|
HOOK_RESPONSE: 'hook_response',
|
||||||
|
TOOL_PROGRESS: 'tool_progress',
|
||||||
|
AUTH_STATUS: 'auth_status',
|
||||||
|
TASK_NOTIFICATION: 'task_notification',
|
||||||
|
TASK_STARTED: 'task_started',
|
||||||
|
TASK_PROGRESS: 'task_progress',
|
||||||
|
SESSION_STATE_CHANGED: 'session_state_changed',
|
||||||
|
FILES_PERSISTED: 'files_persisted',
|
||||||
|
TOOL_USE_SUMMARY: 'tool_use_summary',
|
||||||
|
RATE_LIMIT: 'rate_limit',
|
||||||
|
ELICITATION_COMPLETE: 'elicitation_complete',
|
||||||
|
PROMPT_SUGGESTION: 'prompt_suggestion',
|
||||||
|
STREAMLINED_TEXT: 'streamlined_text',
|
||||||
|
STREAMLINED_TOOL_USE_SUMMARY: 'streamlined_tool_use_summary',
|
||||||
|
POST_TURN_SUMMARY: 'post_turn_summary',
|
||||||
|
CONTROL_RESPONSE: 'control_response',
|
||||||
|
CONTROL_REQUEST: 'control_request',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseStdoutLine(line) {
|
||||||
|
const trimmed = (line || '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeStdinMessage(msg) {
|
||||||
|
return JSON.stringify(msg) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserMessage(text) {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildControlResponse(requestId, result) {
|
||||||
|
return {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: result || {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantMessage(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.ASSISTANT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPartialMessage(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.PARTIAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStreamEvent(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.STREAM_EVENT && msg.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentBlockDelta(msg) {
|
||||||
|
return isStreamEvent(msg) && msg.event.type === 'content_block_delta';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentBlockStart(msg) {
|
||||||
|
return isStreamEvent(msg) && msg.event.type === 'content_block_start';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageStart(msg) {
|
||||||
|
return isStreamEvent(msg) && msg.event.type === 'message_start';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageStop(msg) {
|
||||||
|
return isStreamEvent(msg) && msg.event.type === 'message_stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageDelta(msg) {
|
||||||
|
return isStreamEvent(msg) && msg.event.type === 'message_delta';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResultMessage(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.RESULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolUse(block) {
|
||||||
|
return block && block.type === 'tool_use';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextBlock(block) {
|
||||||
|
return block && block.type === 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThinkingBlock(block) {
|
||||||
|
return block && block.type === 'thinking';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isControlRequest(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.CONTROL_REQUEST;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStatusMessage(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolProgressMessage(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.TOOL_PROGRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionStateChanged(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.SESSION_STATE_CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRateLimitEvent(msg) {
|
||||||
|
return msg && msg.type === MESSAGE_TYPES.RATE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContent(message) {
|
||||||
|
if (!message || !Array.isArray(message.content)) return '';
|
||||||
|
return message.content
|
||||||
|
.filter(b => b.type === 'text')
|
||||||
|
.map(b => b.text || '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolUseBlocks(message) {
|
||||||
|
if (!message || !Array.isArray(message.content)) return [];
|
||||||
|
return message.content.filter(b => b.type === 'tool_use');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MESSAGE_TYPES,
|
||||||
|
parseStdoutLine,
|
||||||
|
serializeStdinMessage,
|
||||||
|
buildUserMessage,
|
||||||
|
buildControlResponse,
|
||||||
|
isAssistantMessage,
|
||||||
|
isPartialMessage,
|
||||||
|
isStreamEvent,
|
||||||
|
isContentBlockDelta,
|
||||||
|
isContentBlockStart,
|
||||||
|
isMessageStart,
|
||||||
|
isMessageStop,
|
||||||
|
isMessageDelta,
|
||||||
|
isResultMessage,
|
||||||
|
isToolUse,
|
||||||
|
isTextBlock,
|
||||||
|
isThinkingBlock,
|
||||||
|
isControlRequest,
|
||||||
|
isStatusMessage,
|
||||||
|
isToolProgressMessage,
|
||||||
|
isSessionStateChanged,
|
||||||
|
isRateLimitEvent,
|
||||||
|
getTextContent,
|
||||||
|
getToolUseBlocks,
|
||||||
|
};
|
||||||
282
vscode-extension/openclaude-vscode/src/chat/sessionManager.js
Normal file
282
vscode-extension/openclaude-vscode/src/chat/sessionManager.js
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* sessionManager — reads JSONL session history from disk, lists sessions,
|
||||||
|
* and provides metadata for the session list UI.
|
||||||
|
*
|
||||||
|
* Session files live under:
|
||||||
|
* ~/.openclaude/projects/<sanitized-cwd>/<sessionId>.jsonl
|
||||||
|
*
|
||||||
|
* Falls back to ~/.claude/projects/ for legacy installs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = require('fs/promises');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const MAX_SANITIZED_LENGTH = 80;
|
||||||
|
|
||||||
|
function sanitizePath(name) {
|
||||||
|
const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
if (sanitized.length <= MAX_SANITIZED_LENGTH) return sanitized;
|
||||||
|
const hash = simpleHash(name);
|
||||||
|
return sanitized.slice(0, MAX_SANITIZED_LENGTH) + '-' + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleHash(str) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(h).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigDir() {
|
||||||
|
const envDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
if (envDir) return envDir;
|
||||||
|
const home = os.homedir();
|
||||||
|
const openClaudeDir = path.join(home, '.openclaude');
|
||||||
|
const legacyDir = path.join(home, '.claude');
|
||||||
|
if (!fs.existsSync(openClaudeDir) && fs.existsSync(legacyDir)) {
|
||||||
|
return legacyDir;
|
||||||
|
}
|
||||||
|
return openClaudeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectsDir() {
|
||||||
|
return path.join(resolveConfigDir(), 'projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectDir(cwd) {
|
||||||
|
return path.join(getProjectsDir(), sanitizePath(cwd));
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionManager {
|
||||||
|
constructor() {
|
||||||
|
this._cwd = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCwd(cwd) {
|
||||||
|
this._cwd = cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions() {
|
||||||
|
const projectDir = this._cwd
|
||||||
|
? getProjectDir(this._cwd)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const dirs = projectDir
|
||||||
|
? [projectDir]
|
||||||
|
: await this._allProjectDirs();
|
||||||
|
|
||||||
|
const sessions = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const items = await this._readSessionDir(dir);
|
||||||
|
sessions.push(...items);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _allProjectDirs() {
|
||||||
|
const base = getProjectsDir();
|
||||||
|
if (!fs.existsSync(base)) return [];
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(base, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory())
|
||||||
|
.map(e => path.join(base, e.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readSessionDir(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
try {
|
||||||
|
const files = await fsp.readdir(dir);
|
||||||
|
const jsonlFiles = files.filter(f => f.endsWith('.jsonl'));
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const file of jsonlFiles) {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
try {
|
||||||
|
const meta = await this._extractSessionMeta(filePath);
|
||||||
|
if (meta) results.push(meta);
|
||||||
|
} catch { /* skip unreadable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _extractSessionMeta(filePath) {
|
||||||
|
const sessionId = path.basename(filePath, '.jsonl');
|
||||||
|
const stat = await fsp.stat(filePath);
|
||||||
|
// Read a larger head because JSONL files often start with system/snapshot
|
||||||
|
// entries before the first user message.
|
||||||
|
const head = await this._readHead(filePath, 65536);
|
||||||
|
const lines = head.split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
let title = null;
|
||||||
|
let preview = '';
|
||||||
|
let timestamp = stat.mtimeMs;
|
||||||
|
let firstTimestamp = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
|
||||||
|
if (!preview && entry.type === 'user' && entry.message) {
|
||||||
|
const content = entry.message.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
preview = content.slice(0, 120);
|
||||||
|
} else if (Array.isArray(content)) {
|
||||||
|
const textBlock = content.find(b => b.type === 'text');
|
||||||
|
preview = textBlock ? (textBlock.text || '').slice(0, 120) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'custom-title' || entry.type === 'session-title') {
|
||||||
|
title = entry.title || entry.name || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'summary' && entry.summary && !title) {
|
||||||
|
title = entry.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.timestamp && !firstTimestamp) {
|
||||||
|
const t = typeof entry.timestamp === 'number'
|
||||||
|
? entry.timestamp
|
||||||
|
: new Date(entry.timestamp).getTime();
|
||||||
|
if (t && !isNaN(t)) firstTimestamp = t;
|
||||||
|
}
|
||||||
|
} catch { /* skip bad line */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstTimestamp) timestamp = firstTimestamp;
|
||||||
|
const timeLabel = formatRelativeTime(timestamp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
title: title || preview.slice(0, 60) || 'Untitled session',
|
||||||
|
preview: preview || '',
|
||||||
|
timestamp,
|
||||||
|
timeLabel,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSession(sessionId) {
|
||||||
|
const projectDir = this._cwd ? getProjectDir(this._cwd) : null;
|
||||||
|
const dirs = projectDir ? [projectDir] : await this._allProjectDirs();
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const filePath = path.join(dir, `${sessionId}.jsonl`);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
return this._parseSessionFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _parseSessionFile(filePath) {
|
||||||
|
const content = await fsp.readFile(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n').filter(Boolean);
|
||||||
|
const messages = [];
|
||||||
|
const toolResults = new Map();
|
||||||
|
|
||||||
|
// First pass: collect tool results from user messages
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'user' && entry.message && Array.isArray(entry.message.content)) {
|
||||||
|
for (const block of entry.message.content) {
|
||||||
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||||
|
const resultText = typeof block.content === 'string'
|
||||||
|
? block.content
|
||||||
|
: Array.isArray(block.content)
|
||||||
|
? block.content.map(b => b.text || '').join('')
|
||||||
|
: '';
|
||||||
|
toolResults.set(String(block.tool_use_id), {
|
||||||
|
content: resultText.slice(0, 2000),
|
||||||
|
isError: block.is_error || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: build messages with tool use details
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'user' && entry.message) {
|
||||||
|
const c = entry.message.content;
|
||||||
|
// Skip tool result messages (they're user messages with tool_result blocks)
|
||||||
|
if (Array.isArray(c) && c.length > 0 && c[0].type === 'tool_result') continue;
|
||||||
|
const text = typeof c === 'string'
|
||||||
|
? c
|
||||||
|
: Array.isArray(c)
|
||||||
|
? c.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||||
|
: '';
|
||||||
|
if (text) messages.push({ role: 'user', text });
|
||||||
|
} else if (entry.type === 'assistant' && entry.message) {
|
||||||
|
const c = entry.message.content;
|
||||||
|
const text = typeof c === 'string'
|
||||||
|
? c
|
||||||
|
: Array.isArray(c)
|
||||||
|
? c.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||||
|
: '';
|
||||||
|
const toolUses = Array.isArray(c)
|
||||||
|
? c.filter(b => b.type === 'tool_use').map(tu => {
|
||||||
|
const result = toolResults.get(String(tu.id));
|
||||||
|
return {
|
||||||
|
id: tu.id,
|
||||||
|
name: tu.name,
|
||||||
|
input: tu.input || null,
|
||||||
|
status: result ? (result.isError ? 'error' : 'complete') : 'complete',
|
||||||
|
result: result ? result.content : null,
|
||||||
|
isError: result ? result.isError : false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
messages.push({ role: 'assistant', text, toolUses });
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _readHead(filePath, bytes) {
|
||||||
|
const fd = await fsp.open(filePath, 'r');
|
||||||
|
try {
|
||||||
|
const buf = Buffer.alloc(bytes);
|
||||||
|
const { bytesRead } = await fd.read(buf, 0, bytes, 0);
|
||||||
|
return buf.slice(0, bytesRead).toString('utf8');
|
||||||
|
} finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(ts) {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - ts;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'Just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
const date = new Date(ts);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SessionManager };
|
||||||
@@ -12,6 +12,9 @@ const {
|
|||||||
resolveCommandCheckPath,
|
resolveCommandCheckPath,
|
||||||
} = require('./state');
|
} = require('./state');
|
||||||
const { buildControlCenterViewModel } = require('./presentation');
|
const { buildControlCenterViewModel } = require('./presentation');
|
||||||
|
const { ChatController, OpenClaudeChatViewProvider, OpenClaudeChatPanelManager } = require('./chat/chatProvider');
|
||||||
|
const { SessionManager } = require('./chat/sessionManager');
|
||||||
|
const { DiffContentProvider, SCHEME: DIFF_SCHEME } = require('./chat/diffController');
|
||||||
|
|
||||||
const OPENCLAUDE_REPO_URL = 'https://github.com/Gitlawb/openclaude';
|
const OPENCLAUDE_REPO_URL = 'https://github.com/Gitlawb/openclaude';
|
||||||
const OPENCLAUDE_SETUP_URL = 'https://github.com/Gitlawb/openclaude/blob/main/README.md#quick-start';
|
const OPENCLAUDE_SETUP_URL = 'https://github.com/Gitlawb/openclaude/blob/main/README.md#quick-start';
|
||||||
@@ -1041,11 +1044,58 @@ class OpenClaudeControlCenterProvider {
|
|||||||
* @param {vscode.ExtensionContext} context
|
* @param {vscode.ExtensionContext} context
|
||||||
*/
|
*/
|
||||||
function activate(context) {
|
function activate(context) {
|
||||||
|
// ── Control Center (existing) ──
|
||||||
const provider = new OpenClaudeControlCenterProvider();
|
const provider = new OpenClaudeControlCenterProvider();
|
||||||
const refreshProvider = () => {
|
const refreshProvider = () => {
|
||||||
void provider.refresh();
|
void provider.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Chat system ──
|
||||||
|
const sessionManager = new SessionManager();
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
sessionManager.setCwd(folders[0].uri.fsPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatController = new ChatController(sessionManager);
|
||||||
|
const chatViewProvider = new OpenClaudeChatViewProvider(chatController);
|
||||||
|
const chatPanelManager = new OpenClaudeChatPanelManager(chatController);
|
||||||
|
|
||||||
|
// ── Diff content provider ──
|
||||||
|
const diffProvider = new DiffContentProvider();
|
||||||
|
const diffProviderReg = vscode.workspace.registerTextDocumentContentProvider(
|
||||||
|
DIFF_SCHEME,
|
||||||
|
diffProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Status bar ──
|
||||||
|
const statusBarItem = vscode.window.createStatusBarItem(
|
||||||
|
vscode.StatusBarAlignment.Right,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
statusBarItem.text = '$(comment-discussion) OpenClaude';
|
||||||
|
statusBarItem.tooltip = 'Open OpenClaude Chat';
|
||||||
|
statusBarItem.command = 'openclaude.openChat';
|
||||||
|
statusBarItem.show();
|
||||||
|
|
||||||
|
chatController.onDidChangeState((state) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'streaming':
|
||||||
|
statusBarItem.text = '$(sync~spin) OpenClaude';
|
||||||
|
statusBarItem.tooltip = 'OpenClaude is generating...';
|
||||||
|
break;
|
||||||
|
case 'connected':
|
||||||
|
statusBarItem.text = '$(comment-discussion) OpenClaude';
|
||||||
|
statusBarItem.tooltip = 'OpenClaude connected';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusBarItem.text = '$(comment-discussion) OpenClaude';
|
||||||
|
statusBarItem.tooltip = 'Open OpenClaude Chat';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Existing commands ──
|
||||||
const startCommand = vscode.commands.registerCommand('openclaude.start', async () => {
|
const startCommand = vscode.commands.registerCommand('openclaude.start', async () => {
|
||||||
await launchOpenClaude();
|
await launchOpenClaude();
|
||||||
});
|
});
|
||||||
@@ -1079,32 +1129,95 @@ function activate(context) {
|
|||||||
await vscode.commands.executeCommand('workbench.view.extension.openclaude');
|
await vscode.commands.executeCommand('workbench.view.extension.openclaude');
|
||||||
});
|
});
|
||||||
|
|
||||||
const providerDisposable = vscode.window.registerWebviewViewProvider(
|
// ── New chat commands ──
|
||||||
|
const newChatCommand = vscode.commands.registerCommand('openclaude.newChat', () => {
|
||||||
|
chatController.stopSession();
|
||||||
|
chatController.broadcast({ type: 'session_cleared' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const openChatCommand = vscode.commands.registerCommand('openclaude.openChat', () => {
|
||||||
|
chatPanelManager.openPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeSessionCommand = vscode.commands.registerCommand('openclaude.resumeSession', async () => {
|
||||||
|
const sessions = await sessionManager.listSessions();
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
await vscode.window.showInformationMessage('No sessions found to resume.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = sessions.slice(0, 30).map(s => ({
|
||||||
|
label: s.title || s.id,
|
||||||
|
description: s.timeLabel,
|
||||||
|
detail: s.preview,
|
||||||
|
sessionId: s.id,
|
||||||
|
}));
|
||||||
|
const picked = await vscode.window.showQuickPick(items, {
|
||||||
|
placeHolder: 'Select a session to resume',
|
||||||
|
});
|
||||||
|
if (picked) {
|
||||||
|
chatController.stopSession();
|
||||||
|
chatController.broadcast({ type: 'session_cleared' });
|
||||||
|
await chatController.startSession({ sessionId: picked.sessionId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortChatCommand = vscode.commands.registerCommand('openclaude.abortChat', () => {
|
||||||
|
chatController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Register providers ──
|
||||||
|
const controlCenterProviderReg = vscode.window.registerWebviewViewProvider(
|
||||||
'openclaude.controlCenter',
|
'openclaude.controlCenter',
|
||||||
provider,
|
provider,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chatViewProviderReg = vscode.window.registerWebviewViewProvider(
|
||||||
|
'openclaude.chat',
|
||||||
|
chatViewProvider,
|
||||||
|
{ webviewOptions: { retainContextWhenHidden: true } },
|
||||||
|
);
|
||||||
|
|
||||||
const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`);
|
const profileWatcher = vscode.workspace.createFileSystemWatcher(`**/${PROFILE_FILE_NAME}`);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
|
// existing
|
||||||
startCommand,
|
startCommand,
|
||||||
startInWorkspaceRootCommand,
|
startInWorkspaceRootCommand,
|
||||||
openDocsCommand,
|
openDocsCommand,
|
||||||
openSetupDocsCommand,
|
openSetupDocsCommand,
|
||||||
openWorkspaceProfileCommand,
|
openWorkspaceProfileCommand,
|
||||||
openUiCommand,
|
openUiCommand,
|
||||||
providerDisposable,
|
controlCenterProviderReg,
|
||||||
|
// new chat
|
||||||
|
newChatCommand,
|
||||||
|
openChatCommand,
|
||||||
|
resumeSessionCommand,
|
||||||
|
abortChatCommand,
|
||||||
|
chatViewProviderReg,
|
||||||
|
diffProviderReg,
|
||||||
|
statusBarItem,
|
||||||
|
// watchers
|
||||||
profileWatcher,
|
profileWatcher,
|
||||||
vscode.workspace.onDidChangeConfiguration(event => {
|
vscode.workspace.onDidChangeConfiguration(event => {
|
||||||
if (event.affectsConfiguration('openclaude')) {
|
if (event.affectsConfiguration('openclaude')) {
|
||||||
refreshProvider();
|
refreshProvider();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
vscode.workspace.onDidChangeWorkspaceFolders(refreshProvider),
|
vscode.workspace.onDidChangeWorkspaceFolders((e) => {
|
||||||
|
refreshProvider();
|
||||||
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
sessionManager.setCwd(folders[0].uri.fsPath);
|
||||||
|
}
|
||||||
|
}),
|
||||||
vscode.window.onDidChangeActiveTextEditor(refreshProvider),
|
vscode.window.onDidChangeActiveTextEditor(refreshProvider),
|
||||||
profileWatcher.onDidCreate(refreshProvider),
|
profileWatcher.onDidCreate(refreshProvider),
|
||||||
profileWatcher.onDidChange(refreshProvider),
|
profileWatcher.onDidChange(refreshProvider),
|
||||||
profileWatcher.onDidDelete(refreshProvider),
|
profileWatcher.onDidDelete(refreshProvider),
|
||||||
|
// disposables
|
||||||
|
{ dispose: () => chatController.dispose() },
|
||||||
|
{ dispose: () => chatPanelManager.dispose() },
|
||||||
|
{ dispose: () => diffProvider.dispose() },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,4 +1229,7 @@ module.exports = {
|
|||||||
OpenClaudeControlCenterProvider,
|
OpenClaudeControlCenterProvider,
|
||||||
renderControlCenterHtml,
|
renderControlCenterHtml,
|
||||||
resolveLaunchTargets,
|
resolveLaunchTargets,
|
||||||
|
ChatController,
|
||||||
|
OpenClaudeChatViewProvider,
|
||||||
|
OpenClaudeChatPanelManager,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user