Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
f166ec1a4e chore(main): release 0.5.0 (#758)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-20 08:30:58 +08:00
Kevin Codex
13e9f22a83 feat: mask provider api key input (#772) 2026-04-20 08:25:22 +08:00
Kevin Codex
f828171ef1 fix: allow provider recovery during startup (#765) 2026-04-20 06:46:05 +08:00
Allan Almeida
e6e8d9a248 feat: add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization (#770)
When set, disables strict schema normalization for non-Gemini providers.
Useful for OpenAI-compatible endpoints that reject MCP tools with complex
optional params (e.g. list[dict]) with "Extra required key ... supplied"
errors.
2026-04-20 06:45:01 +08:00
Sreedhar Busanelli
2c98be7002 fix: remove cached mcpClient in diagnostic tracking to prevent stale references (#727)
* fix: remove cached mcpClient in diagnostic tracking to prevent stale references

Resolves TODO comment about not caching the connected mcpClient since it can change.

Changes:
- Remove cached mcpClient field from DiagnosticTrackingService
- Add currentMcpClients storage to track active clients
- Update beforeFileEdited, getNewDiagnostics, and ensureFileOpened to accept client parameter
- Add backward-compatible methods to maintain existing API
- Update all callers to use new methods
- Add comprehensive test coverage

This prevents using stale MCP client references during reconnections,
making diagnostic tracking more reliable.

Fixes #TODO

* docs: add my contributions section to README

Add fork-specific section highlighting:
- Diagnostic tracking enhancement (PR #727)
- Technical skills demonstrated
- Links to original project and my work
- Professional contribution showcase

* revert: remove README.md contributions section to comply with reviewer request

- Remove 'My Fork & Contributions' section from README.md
- Keep README.md focused on original project documentation
- Maintain clean, project-focused README as requested by reviewer
2026-04-19 09:02:52 +08:00
3kin0x
b786b765f0 fix(api): drop orphan tool results to satisfy strict role sequence (#745)
* fix(api): drop orphan tool results to satisfy Mistral/OpenAI strict role sequence

* test: add test for orphan tool results and restore gemini comments
2026-04-19 08:57:14 +08:00
bpawnzz
55c5f262a9 fix: use raw context window for auto-compact percentage display (#748)
Problem: After auto-compaction with DeepSeek models (e.g., deepseek-chat),
the status line displayed ~16% remaining until next auto-compact, but users
expected ~30% (since compaction reduces usage to roughly half of the full
128k context).

Root cause: calculateTokenWarningState() used the auto-compaction threshold
(effectiveContextWindow - 13k buffer) as the denominator for percentLeft.
For DeepSeek-chat:
- Raw context: 128,000
- Effective: 119,808 (128k - 8,192 output reservation)
- Threshold: 106,808 (effective - 13k buffer)
At 90k usage:
  - Old: (106,808 - 90k) / 106,808 ≈ 16%
  - Expected: (128,000 - 90k) / 128,000 ≈ 30%

Fix: Change percentLeft calculation to use raw context window from
getContextWindowForModel() as denominator, while keeping threshold-based
warnings/triggers unchanged. This makes the displayed percentage show
remaining capacity relative to the model's full context size.

Impact:
- UI now shows correct % of total context remaining
- Auto-compaction trigger point unchanged (still ~90% of effective window)
- All other threshold calculations unaffected

Testing:
- Manual verification: DeepSeek-chat at 90k tokens shows 30% remaining (was 16%)
- Manual verification: Threshold still triggers at ~106k tokens
- Build succeeds: npm run build
- No breaking changes: Callers only depend on percentLeft for display; threshold logic unchanged

Fixes the user-reported discrepancy for DeepSeek and other OpenAI-compatible models.
2026-04-19 08:55:41 +08:00
Kagura
002a8f1f6d fix(mcp): sync required array with properties in tool schemas (#754)
* fix(mcp): sync required array with properties in tool schemas

MCP servers can emit schemas where the required array contains keys
not present in properties. This causes API 400 errors:
"Extra required key 'X' supplied."

- Add sanitizeSchemaRequired() to filter required arrays
- Apply it to MCP tool inputJSONSchema before sending to API
- Also fix filterSwarmFieldsFromSchema to update required after
  removing properties

Fixes #525

* test: add MCP schema required sanitization test
2026-04-19 06:44:25 +08:00
dhenuh
3d1979ff06 fix(help): prevent /help tab crash from undefined descriptions (#732)
- Guard formatDescriptionWithSource() so missing command descriptions become ''
- Harden truncate helpers to accept undefined text/path safely
- Add regression tests covering undefined input cases
2026-04-19 06:38:44 +08:00
lunamonke
b0d9fe7112 Provider loading fix (#623)
* add mistral and gemini provider type for profile provider field

* load latest locally selected

* env variables take precedence over json save

* add gemini context windows and fix gemini defaulting for env

* load on startup fix

* fix failing tests

* clarify test message

* fix variable mismatches

* fix failing test

* delete keys and set profile.apiKey for mistral and gemini

* switch model as well when switching provider

* set model when adding a new model
2026-04-18 01:46:20 +08:00
34 changed files with 983 additions and 145 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.4.0"
".": "0.5.0"
}

View File

@@ -1,5 +1,23 @@
# Changelog
## [0.5.0](https://github.com/Gitlawb/openclaude/compare/v0.4.0...v0.5.0) (2026-04-20)
### Features
* add OPENCLAUDE_DISABLE_STRICT_TOOLS env var to opt out of strict MCP tool schema normalization ([#770](https://github.com/Gitlawb/openclaude/issues/770)) ([e6e8d9a](https://github.com/Gitlawb/openclaude/commit/e6e8d9a24897e4c9ef08b72df20fabbf8ef27f38))
* mask provider api key input ([#772](https://github.com/Gitlawb/openclaude/issues/772)) ([13e9f22](https://github.com/Gitlawb/openclaude/commit/13e9f22a83a2b0f85f557b1e12c9442ba61241e4))
### Bug Fixes
* allow provider recovery during startup ([#765](https://github.com/Gitlawb/openclaude/issues/765)) ([f828171](https://github.com/Gitlawb/openclaude/commit/f828171ef1ab94e2acf73a28a292799e4e26cc0d))
* **api:** drop orphan tool results to satisfy strict role sequence ([#745](https://github.com/Gitlawb/openclaude/issues/745)) ([b786b76](https://github.com/Gitlawb/openclaude/commit/b786b765f01f392652eaf28ed3579a96b7260a53))
* **help:** prevent /help tab crash from undefined descriptions ([#732](https://github.com/Gitlawb/openclaude/issues/732)) ([3d1979f](https://github.com/Gitlawb/openclaude/commit/3d1979ff066db32415e0c8321af916d81f5f2621))
* **mcp:** sync required array with properties in tool schemas ([#754](https://github.com/Gitlawb/openclaude/issues/754)) ([002a8f1](https://github.com/Gitlawb/openclaude/commit/002a8f1f6de2fcfc917165d828501d3047bad61f))
* remove cached mcpClient in diagnostic tracking to prevent stale references ([#727](https://github.com/Gitlawb/openclaude/issues/727)) ([2c98be7](https://github.com/Gitlawb/openclaude/commit/2c98be700274a4241963b5f43530bf3bd8f8963f))
* use raw context window for auto-compact percentage display ([#748](https://github.com/Gitlawb/openclaude/issues/748)) ([55c5f26](https://github.com/Gitlawb/openclaude/commit/55c5f262a9a5a8be0aa9ae8dc6c7dafc465eb2c6))
## [0.4.0](https://github.com/Gitlawb/openclaude/compare/v0.3.0...v0.4.0) (2026-04-17)

View File

@@ -331,7 +331,8 @@ For larger changes, open an issue first so the scope is clear before implementat
- `bun run build`
- `bun run test:coverage`
- `bun run smoke`
- focused `bun test ...` runs for touched areas
- focused `bun test ...` runs for files and flows you changed
## Disclaimer

View File

@@ -1,6 +1,6 @@
{
"name": "@gitlawb/openclaude",
"version": "0.4.0",
"version": "0.5.0",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module",
"bin": {

30
src/commands.test.ts Normal file
View File

@@ -0,0 +1,30 @@
import { formatDescriptionWithSource } from './commands.js'
describe('formatDescriptionWithSource', () => {
test('returns empty text for prompt commands missing a description', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'builtin',
description: undefined,
} as any
expect(formatDescriptionWithSource(command)).toBe('')
})
test('formats plugin commands with missing description safely', () => {
const command = {
name: 'example',
type: 'prompt',
source: 'plugin',
description: undefined,
pluginInfo: {
pluginManifest: {
name: 'MyPlugin',
},
},
} as any
expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ')
})
})

View File

@@ -740,23 +740,23 @@ export function getCommand(commandName: string, commands: Command[]): Command {
*/
export function formatDescriptionWithSource(cmd: Command): string {
if (cmd.type !== 'prompt') {
return cmd.description
return cmd.description ?? ''
}
if (cmd.kind === 'workflow') {
return `${cmd.description} (workflow)`
return `${cmd.description ?? ''} (workflow)`
}
if (cmd.source === 'plugin') {
const pluginName = cmd.pluginInfo?.pluginManifest.name
if (pluginName) {
return `(${pluginName}) ${cmd.description}`
return `(${pluginName}) ${cmd.description ?? ''}`
}
return `${cmd.description} (plugin)`
return `${cmd.description ?? ''} (plugin)`
}
if (cmd.source === 'builtin' || cmd.source === 'mcp') {
return cmd.description
return cmd.description ?? ''
}
if (cmd.source === 'bundled') {

View File

@@ -401,7 +401,7 @@ test('buildCodexProfileEnv derives oauth source from secure storage when no expl
})
})
test('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', async () => {
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex'
@@ -430,18 +430,18 @@ test('applySavedProfileToCurrentSession switches the current env to the saved Co
expect(warning).toBeNull()
expect(processEnv.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBe('codex-live')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex')
expect(processEnv.OPENAI_API_KEY).toBeUndefined()
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeUndefined()
expect(processEnv.OPENAI_API_KEY).toBe("sk-openai")
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', async () => {
test('explicitly declared env takes precedence over applySavedProfileToCurrentSession', async () => {
// @ts-expect-error cache-busting query string for Bun module mocks
const { applySavedProfileToCurrentSession } = await import(
'../../utils/providerProfile.js?apply-saved-profile-codex-oauth'
@@ -465,13 +465,13 @@ test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OA
processEnv,
})
expect(warning).toBeNull()
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(warning).not.toBeUndefined()
expect(processEnv.OPENAI_MODEL).toBe('gpt-4o')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
"https://api.openai.com/v1",
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale')
expect(processEnv.CODEX_API_KEY).toBe("stale-codex-key")
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})
@@ -487,8 +487,8 @@ test('buildCurrentProviderSummary redacts poisoned model and endpoint values', (
})
expect(summary.providerLabel).toBe('OpenAI-compatible')
expect(summary.modelLabel).toBe('sk-...5678')
expect(summary.endpointLabel).toBe('sk-...5678')
expect(summary.modelLabel).toBe('sk-...678')
expect(summary.endpointLabel).toBe('sk-...678')
})
test('buildCurrentProviderSummary labels generic local openai-compatible providers', () => {

View File

@@ -3,6 +3,7 @@ import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useSetAppState } from '../state/AppState.js'
import type { ProviderProfile } from '../utils/config.js'
import {
clearCodexCredentials,
@@ -581,6 +582,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}
setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
mainLoopModelForSession: null,
}))
refreshProfiles()
setAppState(prev => ({
...prev,
@@ -609,6 +615,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}))
providerLabel = active.name
setAppState(prev => ({
...prev,
mainLoopModel: active.model,
mainLoopModelForSession: null,
}))
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
@@ -801,6 +812,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}
const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id
if (isActiveSavedProfile) {
setAppState(prev => ({
...prev,
mainLoopModel: saved.model,
mainLoopModelForSession: null,
}))
}
const settingsOverrideError = isActiveSavedProfile
? clearStartupProviderOverrideFromUserSettings()
: null
@@ -1132,6 +1150,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
focus={true}
showCursor={true}
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
mask={currentStepKey === 'apiKey' ? '*' : undefined}
columns={80}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}

View File

@@ -6,6 +6,7 @@ import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import { maskTextWithVisibleEdges } from '../utils/Cursor.js'
import TextInput from './TextInput.js'
import VimTextInput from './VimTextInput.js'
@@ -199,6 +200,13 @@ test('TextInput renders typed characters before delayed parent value commits', a
expect(output).not.toContain('Type here...')
})
test('maskTextWithVisibleEdges preserves only the first and last three chars', () => {
expect(maskTextWithVisibleEdges('sk-secret-12345678', '*')).toBe(
'sk-************678',
)
expect(maskTextWithVisibleEdges('abcdef', '*')).toBe('******')
})
test('VimTextInput preserves rapid typed characters before delayed parent value commits', async () => {
const { stdout, stdin, getOutput } = createTestStreams()
const root = await createRoot({

View File

@@ -5,7 +5,7 @@ import {
} from '../utils/providerProfile.js'
import {
getProviderValidationError,
validateProviderEnvOrExit,
validateProviderEnvForStartupOrExit,
} from '../utils/providerValidation.js'
// OpenClaude: polyfill globalThis.File for Node < 20.
@@ -132,7 +132,7 @@ async function main(): Promise<void> {
hydrateGithubModelsTokenFromSecureStorage()
}
await validateProviderEnvOrExit()
await validateProviderEnvForStartupOrExit()
// Print the gradient startup screen before the Ink UI loads
const { printStartupScreen } = await import('../components/StartupScreen.js')

View File

@@ -2856,3 +2856,91 @@ test('classifies chat-completions endpoint 404 failures with endpoint_not_found
}),
).rejects.toThrow('openai_category=endpoint_not_found')
})
test('preserves valid tool_result and drops orphan tool_result', async () => {
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'mistral-large-latest',
choices: [
{
message: {
role: 'assistant',
content: 'done',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'mistral-large-latest',
system: 'test system',
messages: [
{ role: 'user', content: 'Search and then I will interrupt' },
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'valid_call_1',
name: 'Search',
input: { query: 'openclaude' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'valid_call_1',
content: 'Found it!',
},
{
type: 'tool_result',
tool_use_id: 'orphan_call_2',
content: 'Interrupted result',
},
{
role: 'user',
content: 'What happened?',
}
],
},
],
max_tokens: 64,
stream: false,
})
const messages = requestBody?.messages as Array<Record<string, unknown>>
// Should have: system, user, assistant (tool_use), tool (valid_call_1), user
// Should NOT have: tool (orphan_call_2)
const toolMessages = messages.filter(m => m.role === 'tool')
expect(toolMessages.length).toBe(1)
expect(toolMessages[0].tool_call_id).toBe('valid_call_1')
const orphanMessage = toolMessages.find(m => m.tool_call_id === 'orphan_call_2')
expect(orphanMessage).toBeUndefined()
})

View File

@@ -349,6 +349,7 @@ function convertMessages(
system: unknown,
): OpenAIMessage[] {
const result: OpenAIMessage[] = []
const knownToolCallIds = new Set<string>()
// System message first
const sysText = convertSystemPrompt(system)
@@ -368,13 +369,21 @@ function convertMessages(
const toolResults = content.filter((b: { type?: string }) => b.type === 'tool_result')
const otherContent = content.filter((b: { type?: string }) => b.type !== 'tool_result')
// Emit tool results as tool messages
// Emit tool results as tool messages, but ONLY if we have a matching tool_use ID.
// Mistral/OpenAI strictly require tool messages to follow an assistant message with tool_calls.
// If the user interrupted (ESC) and a synthetic tool_result was generated without a recorded tool_use,
// emitting it here would cause a "role must alternate" or "unexpected role" error.
for (const tr of toolResults) {
result.push({
role: 'tool',
tool_call_id: tr.tool_use_id ?? 'unknown',
content: convertToolResultContent(tr.content, tr.is_error),
})
const id = tr.tool_use_id ?? 'unknown'
if (knownToolCallIds.has(id)) {
result.push({
role: 'tool',
tool_call_id: id,
content: convertToolResultContent(tr.content, tr.is_error),
})
} else {
logForDebugging(`Dropping orphan tool_result for ID: ${id} to prevent API error`)
}
}
// Emit remaining user content
@@ -415,9 +424,11 @@ function convertMessages(
input?: unknown
extra_content?: Record<string, unknown>
signature?: string
}, index) => {
}) => {
const id = tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`
knownToolCallIds.add(id)
const toolCall: NonNullable<OpenAIMessage['tool_calls']>[number] = {
id: tu.id ?? `call_${crypto.randomUUID().replace(/-/g, '')}`,
id,
type: 'function' as const,
function: {
name: tu.name ?? 'unknown',
@@ -442,7 +453,6 @@ function convertMessages(
// Merge into existing google-specific metadata if present
const existingGoogle = (toolCall.extra_content?.google as Record<string, unknown>) ?? {}
toolCall.extra_content = {
...toolCall.extra_content,
google: {
@@ -597,7 +607,10 @@ function convertTools(
function: {
name: t.name,
description: t.description ?? '',
parameters: normalizeSchemaForOpenAI(schema, !isGemini),
parameters: normalizeSchemaForOpenAI(
schema,
!isGemini && !isEnvTruthy(process.env.OPENCLAUDE_DISABLE_STRICT_TOOLS),
),
},
}
})
@@ -1647,7 +1660,7 @@ class OpenAIShimMessages {
} catch (error) {
throwClassifiedTransportError(error, responsesUrl)
}
if (responsesResponse.ok) {
return responsesResponse
}

View File

@@ -14,6 +14,7 @@ import {
asTrimmedString,
parseChatgptAccountId,
} from './codexOAuthShared.js'
import { DEFAULT_GEMINI_BASE_URL } from 'src/utils/providerProfile.js'
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
@@ -381,11 +382,15 @@ export function resolveProviderRequest(options?: {
}): ResolvedProviderRequest {
const isGithubMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const isMistralMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_MISTRAL)
const isGeminiMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const requestedModel =
options?.model?.trim() ||
(isMistralMode
? process.env.MISTRAL_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
(isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
options?.fallbackModel?.trim() ||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel)
@@ -396,14 +401,25 @@ export function resolveProviderRequest(options?: {
'MISTRAL_BASE_URL',
)
const normalizedGeminiEnvBaseUrl = asNamedEnvUrl(
process.env.GEMINI_BASE_URL,
'GEMINI_BASE_URL',
)
const primaryEnvBaseUrl = isMistralMode
? normalizedMistralEnvBaseUrl
: isGeminiMode
? normalizedGeminiEnvBaseUrl
: 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)
: isGeminiMode
? (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE') ?? DEFAULT_GEMINI_BASE_URL
: undefined)
: (primaryEnvBaseUrl === undefined
? asNamedEnvUrl(process.env.OPENAI_API_BASE, 'OPENAI_API_BASE')
: undefined)

View File

@@ -110,9 +110,14 @@ export function calculateTokenWarningState(
? autoCompactThreshold
: getEffectiveContextWindowSize(model)
// Use the raw context window (without output reservation) for the percentage
// display, so users see remaining context relative to the model's full capacity.
// The threshold (which subtracts buffer) should only affect when we warn/compact,
// not what percentage we display.
const rawContextWindow = getContextWindowForModel(model, getSdkBetas())
const percentLeft = Math.max(
0,
Math.round(((threshold - tokenUsage) / threshold) * 100),
Math.round(((rawContextWindow - tokenUsage) / rawContextWindow) * 100),
)
const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS

View File

@@ -0,0 +1,152 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { DiagnosticTrackingService } from './diagnosticTracking.js'
import type { MCPServerConnection } from './mcp/types.js'
// Mock the IDE client utility
const mockGetConnectedIdeClient = (clients: MCPServerConnection[]) =>
clients.find(client => client.type === 'connected')
describe('DiagnosticTrackingService', () => {
let service: DiagnosticTrackingService
let mockClients: MCPServerConnection[]
let mockIdeClient: MCPServerConnection
beforeEach(() => {
// Get fresh instance for each test
service = DiagnosticTrackingService.getInstance()
// Setup mock clients
mockIdeClient = {
type: 'connected',
name: 'test-ide',
capabilities: {},
config: {},
cleanup: async () => {},
client: {
request: async () => ({}),
setNotificationHandler: () => {},
close: async () => {},
},
} as unknown as MCPServerConnection
mockClients = [
{ type: 'disconnected', name: 'test-disconnected', config: {} } as unknown as MCPServerConnection,
mockIdeClient,
]
})
afterEach(async () => {
await service.shutdown()
})
describe('handleQueryStart', () => {
test('should store MCP clients and initialize service', async () => {
await service.handleQueryStart(mockClients)
// Service should be initialized
expect(service).toBeDefined()
// Should be able to get IDE client from stored clients
// We can't directly test private methods, but we can test the behavior
const result = await service.getNewDiagnosticsCompat()
expect(result).toEqual([]) // Should return empty when no diagnostics
})
test('should reset service if already initialized', async () => {
// Initialize first
await service.handleQueryStart(mockClients)
// Call again - should reset without error
await service.handleQueryStart(mockClients)
// Should still work
const result = await service.getNewDiagnosticsCompat()
expect(result).toEqual([])
})
})
describe('backward-compatible methods', () => {
beforeEach(async () => {
await service.handleQueryStart(mockClients)
})
test('beforeFileEditedCompat should work without explicit client', async () => {
// Should not throw error and should return undefined when no IDE client
const result = await service.beforeFileEditedCompat('/test/file.ts')
expect(result).toBeUndefined()
})
test('getNewDiagnosticsCompat should work without explicit client', async () => {
const result = await service.getNewDiagnosticsCompat()
expect(Array.isArray(result)).toBe(true)
})
test('ensureFileOpenedCompat should work without explicit client', async () => {
const result = await service.ensureFileOpenedCompat('/test/file.ts')
expect(result).toBeUndefined()
})
})
describe('new explicit client methods', () => {
test('beforeFileEdited should require client parameter', async () => {
// Should not work without client
const result = await service.beforeFileEdited('/test/file.ts', undefined as any)
expect(result).toBeUndefined()
})
test('getNewDiagnostics should require client parameter', async () => {
// Should not work without client
const result = await service.getNewDiagnostics(undefined as any)
expect(result).toEqual([])
})
test('ensureFileOpened should require client parameter', async () => {
// Should not work without client
const result = await service.ensureFileOpened('/test/file.ts', undefined as any)
expect(result).toBeUndefined()
})
})
describe('shutdown', () => {
test('should clear stored clients on shutdown', async () => {
await service.handleQueryStart(mockClients)
// Verify service is working
const beforeResult = await service.getNewDiagnosticsCompat()
expect(Array.isArray(beforeResult)).toBe(true)
// Shutdown
await service.shutdown()
// After shutdown, compat methods should return empty results
const afterResult = await service.getNewDiagnosticsCompat()
expect(afterResult).toEqual([])
})
})
describe('integration with existing functionality', () => {
test('should maintain existing diagnostic tracking behavior', async () => {
await service.handleQueryStart(mockClients)
// Test baseline tracking
await service.beforeFileEditedCompat('/test/file.ts')
// Test getting new diagnostics (should be empty since no IDE client is actually connected)
const newDiagnostics = await service.getNewDiagnosticsCompat()
expect(Array.isArray(newDiagnostics)).toBe(true)
})
test('should handle missing IDE client gracefully', async () => {
// Test with no connected clients
const noIdeClients = [
{ type: 'disconnected', name: 'test-disconnected-2', config: {} } as unknown as MCPServerConnection,
]
await service.handleQueryStart(noIdeClients)
// Should handle gracefully
const result = await service.getNewDiagnosticsCompat()
expect(result).toEqual([])
})
})
})

View File

@@ -32,7 +32,7 @@ export class DiagnosticTrackingService {
private baseline: Map<string, Diagnostic[]> = new Map()
private initialized = false
private mcpClient: MCPServerConnection | undefined
private currentMcpClients: MCPServerConnection[] = []
// Track when files were last processed/fetched
private lastProcessedTimestamps: Map<string, number> = new Map()
@@ -48,18 +48,17 @@ export class DiagnosticTrackingService {
return DiagnosticTrackingService.instance
}
initialize(mcpClient: MCPServerConnection) {
initialize() {
if (this.initialized) {
return
}
// TODO: Do not cache the connected mcpClient since it can change.
this.mcpClient = mcpClient
this.initialized = true
}
async shutdown(): Promise<void> {
this.initialized = false
this.currentMcpClients = []
this.baseline.clear()
this.rightFileDiagnosticsState.clear()
this.lastProcessedTimestamps.clear()
@@ -75,6 +74,46 @@ export class DiagnosticTrackingService {
this.lastProcessedTimestamps.clear()
}
/**
* Get the current IDE client from stored MCP clients
*/
private getCurrentIdeClient(): MCPServerConnection | undefined {
return getConnectedIdeClient(this.currentMcpClients)
}
/**
* Backward-compatible method that uses stored IDE client
*/
async beforeFileEditedCompat(filePath: string): Promise<void> {
const ideClient = this.getCurrentIdeClient()
if (!ideClient) {
return
}
return await this.beforeFileEdited(filePath, ideClient)
}
/**
* Backward-compatible method that uses stored IDE client
*/
async getNewDiagnosticsCompat(): Promise<DiagnosticFile[]> {
const ideClient = this.getCurrentIdeClient()
if (!ideClient) {
return []
}
return await this.getNewDiagnostics(ideClient)
}
/**
* Backward-compatible method that uses stored IDE client
*/
async ensureFileOpenedCompat(fileUri: string): Promise<void> {
const ideClient = this.getCurrentIdeClient()
if (!ideClient) {
return
}
return await this.ensureFileOpened(fileUri, ideClient)
}
private normalizeFileUri(fileUri: string): string {
// Remove our protocol prefixes
const protocolPrefixes = [
@@ -100,11 +139,11 @@ export class DiagnosticTrackingService {
* Ensure a file is opened in the IDE before processing.
* This is important for language services like diagnostics to work properly.
*/
async ensureFileOpened(fileUri: string): Promise<void> {
async ensureFileOpened(fileUri: string, mcpClient: MCPServerConnection): Promise<void> {
if (
!this.initialized ||
!this.mcpClient ||
this.mcpClient.type !== 'connected'
!mcpClient ||
mcpClient.type !== 'connected'
) {
return
}
@@ -121,7 +160,7 @@ export class DiagnosticTrackingService {
selectToEndOfLine: false,
makeFrontmost: false,
},
this.mcpClient,
mcpClient,
)
} catch (error) {
logError(error as Error)
@@ -132,11 +171,11 @@ export class DiagnosticTrackingService {
* Capture baseline diagnostics for a specific file before editing.
* This is called before editing a file to ensure we have a baseline to compare against.
*/
async beforeFileEdited(filePath: string): Promise<void> {
async beforeFileEdited(filePath: string, mcpClient: MCPServerConnection): Promise<void> {
if (
!this.initialized ||
!this.mcpClient ||
this.mcpClient.type !== 'connected'
!mcpClient ||
mcpClient.type !== 'connected'
) {
return
}
@@ -147,7 +186,7 @@ export class DiagnosticTrackingService {
const result = await callIdeRpc(
'getDiagnostics',
{ uri: `file://${filePath}` },
this.mcpClient,
mcpClient,
)
const diagnosticFile = this.parseDiagnosticResult(result)[0]
if (diagnosticFile) {
@@ -185,11 +224,11 @@ export class DiagnosticTrackingService {
* Get new diagnostics from file://, _claude_fs_right, and _claude_fs_ URIs that aren't in the baseline.
* Only processes diagnostics for files that have been edited.
*/
async getNewDiagnostics(): Promise<DiagnosticFile[]> {
async getNewDiagnostics(mcpClient: MCPServerConnection): Promise<DiagnosticFile[]> {
if (
!this.initialized ||
!this.mcpClient ||
this.mcpClient.type !== 'connected'
!mcpClient ||
mcpClient.type !== 'connected'
) {
return []
}
@@ -200,7 +239,7 @@ export class DiagnosticTrackingService {
const result = await callIdeRpc(
'getDiagnostics',
{}, // Empty params fetches all diagnostics
this.mcpClient,
mcpClient,
)
allDiagnosticFiles = this.parseDiagnosticResult(result)
} catch (_error) {
@@ -328,13 +367,16 @@ export class DiagnosticTrackingService {
* @param shouldQuery Whether a query is actually being made (not just a command)
*/
async handleQueryStart(clients: MCPServerConnection[]): Promise<void> {
// Store the current MCP clients for later use
this.currentMcpClients = clients
// Only proceed if we should query and have clients
if (!this.initialized) {
// Find the connected IDE client
const connectedIdeClient = getConnectedIdeClient(clients)
if (connectedIdeClient) {
this.initialize(connectedIdeClient)
this.initialize()
}
} else {
// Reset diagnostic tracking for new query loops

View File

@@ -422,7 +422,7 @@ export const FileEditTool = buildTool({
activateConditionalSkillsForPaths([absoluteFilePath], cwd)
}
await diagnosticTracker.beforeFileEdited(absoluteFilePath)
await diagnosticTracker.beforeFileEditedCompat(absoluteFilePath)
// Ensure parent directory exists before the atomic read-modify-write section.
// These awaits must stay OUTSIDE the critical section below — a yield between

View File

@@ -244,7 +244,7 @@ export const FileWriteTool = buildTool({
// Activate conditional skills whose path patterns match this file
activateConditionalSkillsForPaths([fullFilePath], cwd)
await diagnosticTracker.beforeFileEdited(fullFilePath)
await diagnosticTracker.beforeFileEditedCompat(fullFilePath)
// Ensure parent directory exists before the atomic read-modify-write section.
// Must stay OUTSIDE the critical section below (a yield between the staleness

View File

@@ -148,6 +148,42 @@ type Position = {
column: number
}
export function maskTextWithVisibleEdges(
value: string,
mask: string,
visiblePrefix = 3,
visibleSuffix = 3,
): string {
if (!mask || !value) return value
const graphemes = Array.from(getGraphemeSegmenter().segment(value))
const secretGraphemeCount = graphemes.filter(
({ segment }) => segment !== '\n',
).length
const visibleCount = visiblePrefix + visibleSuffix
if (secretGraphemeCount <= visibleCount) {
return graphemes
.map(({ segment }) => (segment === '\n' ? segment : mask))
.join('')
}
let secretIndex = 0
return graphemes
.map(({ segment }) => {
if (segment === '\n') return segment
const nextSegment =
secretIndex < visiblePrefix ||
secretIndex >= secretGraphemeCount - visibleSuffix
? segment
: mask
secretIndex += 1
return nextSegment
})
.join('')
}
export class Cursor {
readonly offset: number
constructor(
@@ -208,7 +244,12 @@ export class Cursor {
maxVisibleLines?: number,
) {
const { line, column } = this.getPosition()
const allLines = this.measuredText.getWrappedText()
const allLines = mask
? new MeasuredText(
maskTextWithVisibleEdges(this.text, mask),
this.measuredText.columns,
).getWrappedText()
: this.measuredText.getWrappedText()
const startLine = this.getViewportStartLine(maxVisibleLines)
const endLine =
@@ -221,23 +262,6 @@ export class Cursor {
.map((text, i) => {
const currentLine = i + startLine
let displayText = text
if (mask) {
const graphemes = Array.from(getGraphemeSegmenter().segment(text))
if (currentLine === allLines.length - 1) {
// Last line: mask all but the trailing 6 chars so the user can
// confirm they pasted the right thing without exposing the full token
const visibleCount = Math.min(6, graphemes.length)
const maskCount = graphemes.length - visibleCount
const splitOffset =
graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0
displayText = mask.repeat(maskCount) + text.slice(splitOffset)
} else {
// Earlier wrapped lines: fully mask. Previously only the last line
// was masked, leaking the start of the token on narrow terminals
// where the pasted OAuth code wraps across multiple lines.
displayText = mask.repeat(graphemes.length)
}
}
// looking for the line with the cursor
if (line !== currentLine) return displayText.trimEnd()

View File

@@ -78,3 +78,28 @@ test('toolToAPISchema keeps skill required for SkillTool', async () => {
required: ['skill'],
})
})
test('toolToAPISchema removes extra required keys not in properties (MCP schema sanitization)', async () => {
const schema = await toolToAPISchema(
{
name: 'mcp__test__create_object',
inputSchema: z.strictObject({}),
inputJSONSchema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name', 'attributes'],
},
prompt: async () => 'Create an object',
} as unknown as Tool,
{
getToolPermissionContext: async () => getEmptyToolPermissionContext(),
tools: [] as unknown as Tools,
agents: [],
},
)
const inputSchema = (schema as { input_schema: { required?: string[] } }).input_schema
expect(inputSchema.required).toEqual(['name'])
})

View File

@@ -111,11 +111,60 @@ function filterSwarmFieldsFromSchema(
delete filteredProps[field]
}
filtered.properties = filteredProps
// Keep `required` in sync after removing properties
if (Array.isArray(filtered.required)) {
filtered.required = filtered.required.filter(
(key: string) => key in filteredProps,
)
}
}
return filtered
}
/**
* Ensure `required` only lists keys present in `properties`.
* MCP servers may emit schemas where these are out of sync, causing
* API 400 errors ("Extra required key supplied").
* Recurses into nested object schemas.
*/
function sanitizeSchemaRequired(
schema: Anthropic.Tool.InputSchema,
): Anthropic.Tool.InputSchema {
if (!schema || typeof schema !== 'object') {
return schema
}
const result = { ...schema }
const props = result.properties as Record<string, unknown> | undefined
if (props && Array.isArray(result.required)) {
result.required = result.required.filter(
(key: string) => key in props,
)
}
// Recurse into nested object properties
if (props) {
const sanitizedProps = { ...props }
for (const [key, value] of Object.entries(sanitizedProps)) {
if (
value &&
typeof value === 'object' &&
(value as Record<string, unknown>).type === 'object'
) {
sanitizedProps[key] = sanitizeSchemaRequired(
value as Anthropic.Tool.InputSchema,
)
}
}
result.properties = sanitizedProps
}
return result
}
export async function toolToAPISchema(
tool: Tool,
options: {
@@ -156,7 +205,7 @@ export async function toolToAPISchema(
// Use tool's JSON schema directly if provided, otherwise convert Zod schema
let input_schema = (
'inputJSONSchema' in tool && tool.inputJSONSchema
? tool.inputJSONSchema
? sanitizeSchemaRequired(tool.inputJSONSchema as Anthropic.Tool.InputSchema)
: zodToJsonSchema(tool.inputSchema)
) as Anthropic.Tool.InputSchema

View File

@@ -2882,7 +2882,7 @@ async function getDiagnosticAttachments(
}
// Get new diagnostics from the tracker (IDE diagnostics via MCP)
const newDiagnostics = await diagnosticTracker.getNewDiagnostics()
const newDiagnostics = await diagnosticTracker.getNewDiagnosticsCompat()
if (newDiagnostics.length === 0) {
return []
}

View File

@@ -155,7 +155,7 @@ export {
NOTIFICATION_CHANNELS,
} from './configConstants.js'
import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js'
import type { EDITOR_MODES, NOTIFICATION_CHANNELS, PROVIDERS } from './configConstants.js'
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]
@@ -181,10 +181,12 @@ export type DiffTool = 'terminal' | 'auto'
export type OutputStyle = string
export type Providers = typeof PROVIDERS[number]
export type ProviderProfile = {
id: string
name: string
provider: 'openai' | 'anthropic'
provider: Providers
baseUrl: string
model: string
apiKey?: string

View File

@@ -19,3 +19,5 @@ export const EDITOR_MODES = ['normal', 'vim'] as const
// 'in-process' = in-process teammates running in same process
// 'auto' = automatically choose based on context (default)
export const TEAMMATE_MODES = ['auto', 'tmux', 'in-process'] as const
export const PROVIDERS = ['openai', 'anthropic', 'mistral', 'gemini'] as const

View File

@@ -181,9 +181,11 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
'google/gemini-2.5-pro': 1_048_576,
// Google (native via CLAUDE_CODE_USE_GEMINI)
'gemini-2.0-flash': 1_048_576,
'gemini-2.5-pro': 1_048_576,
'gemini-2.5-flash': 1_048_576,
'gemini-2.0-flash': 1_048_576,
'gemini-2.5-pro': 1_048_576,
'gemini-2.5-flash': 1_048_576,
'gemini-3.1-pro': 1_048_576,
'gemini-3.1-flash-lite-preview': 1_048_576,
// Ollama local models
// Llama 3.1+ models support 128k context natively (Meta official specs).
@@ -331,9 +333,11 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
'google/gemini-2.5-pro': 65_536,
// Google (native via CLAUDE_CODE_USE_GEMINI)
'gemini-2.0-flash': 8_192,
'gemini-2.5-pro': 65_536,
'gemini-2.5-flash': 65_536,
'gemini-2.0-flash': 8_192,
'gemini-2.5-pro': 65_536,
'gemini-2.5-flash': 65_536,
'gemini-3.1-pro': 65_536,
'gemini-3.1-flash-lite-preview': 65_536,
// Ollama local models (conservative safe defaults)
'llama3.3:70b': 4_096,

View File

@@ -166,7 +166,7 @@ test('matching persisted gemini env is reused for gemini launch', async () => {
assert.equal(env.GEMINI_BASE_URL, 'https://example.test/v1beta/openai')
})
test('gemini launch ignores mismatched persisted openai env and strips other provider secrets', async () => {
test('openai env variables take precedence over gemini', async () => {
const env = await buildLaunchEnv({
profile: 'gemini',
persisted: profile('openai', {
@@ -187,16 +187,16 @@ test('gemini launch ignores mismatched persisted openai env and strips other pro
},
})
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
assert.equal(env.GEMINI_MODEL, 'gemini-2.0-flash')
assert.equal(env.GEMINI_API_KEY, 'gem-live')
assert.equal(env.CLAUDE_CODE_USE_GEMINI, undefined)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1')
assert.equal(env.GEMINI_MODEL, undefined)
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(
env.GEMINI_BASE_URL,
'https://generativelanguage.googleapis.com/v1beta/openai',
undefined,
)
assert.equal(env.GOOGLE_API_KEY, undefined)
assert.equal(env.OPENAI_API_KEY, undefined)
assert.equal(env.OPENAI_API_KEY, 'sk-live')
assert.equal(env.CODEX_API_KEY, undefined)
assert.equal(env.CHATGPT_ACCOUNT_ID, undefined)
})
@@ -562,8 +562,13 @@ test('buildStartupEnvFromProfile leaves explicit provider selections untouched',
processEnv,
})
assert.equal(env, processEnv)
// Remove the strict object equality check: assert.equal(env, processEnv)
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_API_KEY, 'gem-live')
assert.equal(env.GEMINI_MODEL, 'gemini-2.0-flash')
// Add the new default fields injected by the function
assert.equal(env.GEMINI_BASE_URL, 'https://generativelanguage.googleapis.com/v1beta/openai')
assert.equal(env.GEMINI_AUTH_MODE, 'api-key')
assert.equal(env.OPENAI_API_KEY, undefined)
})
@@ -607,14 +612,17 @@ test('buildStartupEnvFromProfile treats explicit falsey provider flags as user i
processEnv,
})
assert.equal(env, processEnv)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, '0')
assert.equal(env.GEMINI_API_KEY, undefined)
assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined)
assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1')
assert.equal(env.GEMINI_API_KEY, 'gem-persisted')
assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash')
assert.equal(env.GEMINI_BASE_URL, 'https://generativelanguage.googleapis.com/v1beta/openai')
assert.equal(env.GEMINI_AUTH_MODE, 'api-key')
})
test('maskSecretForDisplay preserves only a short prefix and suffix', () => {
assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...5678')
assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIza...5678')
assert.equal(maskSecretForDisplay('sk-secret-12345678'), 'sk-...678')
assert.equal(maskSecretForDisplay('AIzaSecret12345678'), 'AIz...678')
})
test('redactSecretValueForDisplay masks poisoned display fields that equal configured secrets', () => {
@@ -622,7 +630,7 @@ test('redactSecretValueForDisplay masks poisoned display fields that equal confi
assert.equal(
redactSecretValueForDisplay(apiKey, { OPENAI_API_KEY: apiKey }),
'sk-...5678',
'sk-...678',
)
assert.equal(
redactSecretValueForDisplay('gpt-4o', { OPENAI_API_KEY: apiKey }),

View File

@@ -29,6 +29,9 @@ export {
sanitizeApiKey,
sanitizeProviderConfigValue,
} from './providerSecrets.js'
import { isEnvTruthy } from './envUtils.ts'
import { PROVIDERS } from './configConstants.js'
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
export const DEFAULT_GEMINI_BASE_URL =
@@ -498,13 +501,13 @@ export function hasExplicitProviderSelection(
}
return (
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined ||
processEnv.CLAUDE_CODE_USE_GITHUB !== undefined ||
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined ||
processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined ||
processEnv.CLAUDE_CODE_USE_BEDROCK !== undefined ||
processEnv.CLAUDE_CODE_USE_VERTEX !== undefined ||
processEnv.CLAUDE_CODE_USE_FOUNDRY !== undefined
isEnvTruthy(processEnv.CLAUDE_CODE_USE_OPENAI) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_GEMINI) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(processEnv.CLAUDE_CODE_USE_FOUNDRY)
)
}
@@ -573,6 +576,20 @@ export async function buildLaunchEnv(options: {
const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY)
const persistedGeminiAuthMode = persistedEnv.GEMINI_AUTH_MODE
if (hasExplicitProviderSelection(processEnv)) {
for (let provider of PROVIDERS) {
if (provider === "anthropic") {
continue;
}
const env_key_name = `CLAUDE_CODE_USE_${provider.toUpperCase()}`
if (env_key_name in processEnv && isEnvTruthy(processEnv[env_key_name])) {
options.profile = provider;
}
}
}
if (options.profile === 'gemini') {
const env: NodeJS.ProcessEnv = {
...processEnv,
@@ -825,12 +842,18 @@ export async function buildStartupEnvFromProfile(options?: {
const persisted = options?.persisted ?? loadProfileFile()
// Saved /provider profiles should still win over provider-manager env that was
// auto-applied during startup. Only explicit shell/flag provider selection
// auto-applied during startup. Only an explicit shell/flag provider selection
// should bypass the persisted startup profile.
//
const profileManagedEnv = processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === '1'
if (hasExplicitProviderSelection(processEnv) && !profileManagedEnv) {
return processEnv
}
// If the user explicitly selected a provider via env, allow it to bypass
// the persisted profile only when we can prove it was managed by the
// persisted profile env itself.
//
// Practically: on initial startup, provider routing env vars can already
// be present due to earlier auto-application steps. We should still apply
// the persisted profile rather than returning early.
if (!persisted) {
return processEnv

View File

@@ -13,6 +13,7 @@ const RESTORED_KEYS = [
'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_MISTRAL',
'CLAUDE_CODE_USE_GITHUB',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
@@ -24,6 +25,15 @@ const RESTORED_KEYS = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'ANTHROPIC_API_KEY',
'GEMINI_BASE_URL',
'GEMINI_MODEL',
'GEMINI_API_KEY',
'GEMINI_AUTH_MODE',
'GEMINI_ACCESS_TOKEN',
'GOOGLE_API_KEY',
'MISTRAL_BASE_URL',
'MISTRAL_MODEL',
'MISTRAL_API_KEY',
] as const
type MockConfigState = {
@@ -98,6 +108,24 @@ function buildProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile
}
}
function buildMistralProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
return buildProfile({
provider: 'mistral',
baseUrl: 'https://api.mistral.ai/v1',
model: 'devstral-latest',
...overrides,
})
}
function buildGeminiProfile(overrides: Partial<ProviderProfile> = {}): ProviderProfile {
return buildProfile({
provider: 'gemini',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
model: 'gemini-3-flash-preview',
...overrides,
})
}
describe('applyProviderProfileToProcessEnv', () => {
test('openai profile clears competing gemini/github flags', async () => {
const { applyProviderProfileToProcessEnv } =
@@ -118,6 +146,36 @@ describe('applyProviderProfileToProcessEnv', () => {
expect(getFreshAPIProvider()).toBe('openai')
})
test('mistral profile sets CLAUDE_CODE_USE_MISTRAL and clears openai flags', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
applyProviderProfileToProcessEnv(buildMistralProfile())
const { getAPIProvider: getFreshAPIProvider } =
await importFreshProvidersModule()
expect(process.env.CLAUDE_CODE_USE_MISTRAL).toBe('1')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.MISTRAL_MODEL).toBe('devstral-latest')
expect(getFreshAPIProvider()).toBe('mistral')
})
test('gemini profile sets CLAUDE_CODE_USE_GEMINI and clears openai flags', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
applyProviderProfileToProcessEnv(buildGeminiProfile())
const { getAPIProvider: getFreshAPIProvider } =
await importFreshProvidersModule()
expect(process.env.CLAUDE_CODE_USE_GEMINI).toBe('1')
expect(process.env.CLAUDE_CODE_USE_OPENAI).toBeUndefined()
expect(process.env.GEMINI_MODEL).toBe('gemini-3-flash-preview')
expect(getFreshAPIProvider()).toBe('gemini')
})
test('anthropic profile clears competing gemini/github flags', async () => {
const { applyProviderProfileToProcessEnv } =
await importFreshProviderProfileModules()

View File

@@ -6,6 +6,14 @@ import {
} from './config.js'
import type { ModelOption } from './model/modelOptions.js'
import { getPrimaryModel, parseModelList } from './providerModels.js'
import {
createProfileFile,
saveProfileFile,
buildGeminiProfileEnv,
buildMistralProfileEnv,
buildOpenAIProfileEnv,
type ProviderProfile as ProviderProfileStartup,
} from './providerProfile.js'
export type ProviderPreset =
| 'anthropic'
@@ -60,7 +68,14 @@ function normalizeBaseUrl(value: string): string {
function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null {
const id = trimValue(profile.id)
const name = trimValue(profile.name)
const provider = profile.provider === 'anthropic' ? 'anthropic' : 'openai'
const provider =
profile.provider === 'anthropic'
? 'anthropic'
: profile.provider === 'mistral'
? 'mistral'
: profile.provider === 'gemini'
? 'gemini'
: 'openai'
const baseUrl = normalizeBaseUrl(profile.baseUrl)
const model = trimValue(profile.model)
@@ -161,7 +176,7 @@ export function getProviderPresetDefaults(
}
case 'gemini':
return {
provider: 'openai',
provider: 'gemini',
name: 'Google Gemini',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
model: 'gemini-3-flash-preview',
@@ -170,7 +185,7 @@ export function getProviderPresetDefaults(
}
case 'mistral':
return {
provider: 'openai',
provider: 'mistral',
name: 'Mistral',
baseUrl: 'https://api.mistral.ai/v1',
model: 'devstral-latest',
@@ -317,6 +332,7 @@ function hasConflictingProviderFlagsForProfile(
return (
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined ||
processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined ||
processEnv.CLAUDE_CODE_USE_GITHUB !== undefined ||
processEnv.CLAUDE_CODE_USE_BEDROCK !== undefined ||
processEnv.CLAUDE_CODE_USE_VERTEX !== undefined ||
@@ -358,6 +374,38 @@ function isProcessEnvAlignedWithProfile(
)
}
if (profile.provider === 'mistral') {
return (
processEnv.CLAUDE_CODE_USE_MISTRAL !== undefined &&
processEnv.CLAUDE_CODE_USE_GEMINI === undefined &&
processEnv.CLAUDE_CODE_USE_OPENAI === undefined &&
processEnv.CLAUDE_CODE_USE_GITHUB === undefined &&
processEnv.CLAUDE_CODE_USE_BEDROCK === undefined &&
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
sameOptionalEnvValue(processEnv.MISTRAL_BASE_URL, profile.baseUrl) &&
sameOptionalEnvValue(processEnv.MISTRAL_MODEL, profile.model) &&
(!includeApiKey ||
sameOptionalEnvValue(processEnv.MISTRAL_API_KEY, profile.apiKey))
)
}
if (profile.provider === 'gemini') {
return (
processEnv.CLAUDE_CODE_USE_GEMINI !== undefined &&
processEnv.CLAUDE_CODE_USE_MISTRAL === undefined &&
processEnv.CLAUDE_CODE_USE_OPENAI === undefined &&
processEnv.CLAUDE_CODE_USE_GITHUB === undefined &&
processEnv.CLAUDE_CODE_USE_BEDROCK === undefined &&
processEnv.CLAUDE_CODE_USE_VERTEX === undefined &&
processEnv.CLAUDE_CODE_USE_FOUNDRY === undefined &&
sameOptionalEnvValue(processEnv.GEMINI_BASE_URL, profile.baseUrl) &&
sameOptionalEnvValue(processEnv.GEMINI_MODEL, profile.model) &&
(!includeApiKey ||
sameOptionalEnvValue(processEnv.GEMINI_API_KEY, profile.apiKey))
)
}
return (
processEnv.CLAUDE_CODE_USE_OPENAI !== undefined &&
processEnv.CLAUDE_CODE_USE_GEMINI === undefined &&
@@ -407,6 +455,17 @@ export function clearProviderProfileEnvFromProcessEnv(
delete processEnv[PROFILE_ENV_APPLIED_FLAG]
delete processEnv[PROFILE_ENV_APPLIED_ID]
delete processEnv.GEMINI_MODEL
delete processEnv.GEMINI_BASE_URL
delete processEnv.GEMINI_API_KEY
delete processEnv.GEMINI_AUTH_MODE
delete processEnv.GEMINI_ACCESS_TOKEN
delete processEnv.GOOGLE_API_KEY
delete processEnv.MISTRAL_MODEL
delete processEnv.MISTRAL_BASE_URL
delete processEnv.MISTRAL_API_KEY
// Clear provider-specific API keys
delete processEnv.MINIMAX_API_KEY
delete processEnv.NVIDIA_API_KEY
@@ -435,6 +494,40 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void
return
}
if (profile.provider === 'mistral') {
process.env.CLAUDE_CODE_USE_MISTRAL = '1'
process.env.MISTRAL_BASE_URL = profile.baseUrl
process.env.MISTRAL_MODEL = profile.model
if (profile.apiKey) {
process.env.MISTRAL_API_KEY = profile.apiKey
} else {
delete process.env.MISTRAL_API_KEY
}
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_MODEL
return
}
if (profile.provider === 'gemini') {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
process.env.GEMINI_BASE_URL = profile.baseUrl
process.env.GEMINI_MODEL = profile.model
if (profile.apiKey) {
process.env.GEMINI_API_KEY = profile.apiKey
} else {
delete process.env.GEMINI_API_KEY
}
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_MODEL
return
}
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = profile.baseUrl
process.env.OPENAI_MODEL = getPrimaryModel(profile.model)
@@ -520,7 +613,7 @@ export function addProviderProfile(
const activeProfile = getActiveProviderProfile()
if (activeProfile?.id === profile.id) {
applyProviderProfileToProcessEnv(profile)
setActiveProviderProfile(profile.id)
clearActiveOpenAIModelOptionsCache()
}
@@ -699,6 +792,68 @@ export function setActiveProviderProfile(
}))
applyProviderProfileToProcessEnv(activeProfile)
// Keep startup persisted provider profile in sync so initial startup
// uses the selected provider/model.
const persistedProfile = (() => {
if (activeProfile.provider === 'anthropic') return 'openai' as const
return activeProfile.provider
})()
const profileEnv = (() => {
switch (activeProfile.provider) {
case 'gemini':
return (
buildGeminiProfileEnv({
model: activeProfile.model,
baseUrl: activeProfile.baseUrl,
apiKey: activeProfile.apiKey,
authMode: 'api-key',
processEnv: process.env,
}) ?? null
)
case 'mistral':
return (
buildMistralProfileEnv({
model: activeProfile.model,
baseUrl: activeProfile.baseUrl,
apiKey: activeProfile.apiKey,
processEnv: process.env,
}) ?? null
)
default:
// anthropic and all openai-compatible providers
return (
buildOpenAIProfileEnv({
model: activeProfile.model,
baseUrl: activeProfile.baseUrl,
apiKey: activeProfile.apiKey,
processEnv: process.env,
}) ?? null
)
}
})()
if (profileEnv) {
const startupProfile =
activeProfile.provider === 'anthropic'
? ({
profile: 'openai' as ProviderProfileStartup,
env: {
OPENAI_BASE_URL: activeProfile.baseUrl,
OPENAI_MODEL: activeProfile.model,
OPENAI_API_KEY: activeProfile.apiKey,
},
} as const)
: ({
profile: activeProfile.provider as ProviderProfileStartup,
env: profileEnv,
} as const)
const file = createProfileFile(startupProfile.profile, startupProfile.env)
saveProfileFile(file)
}
return activeProfile
}

View File

@@ -61,15 +61,7 @@ export function maskSecretForDisplay(
return 'configured'
}
if (sanitized.startsWith('sk-')) {
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
}
if (sanitized.startsWith('AIza')) {
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
}
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
return `${sanitized.slice(0, 3)}...${sanitized.slice(-3)}`
}
export function redactSecretValueForDisplay(

View File

@@ -1,6 +1,9 @@
import { afterEach, expect, test } from 'bun:test'
import { getProviderValidationError } from './providerValidation.ts'
import {
getProviderValidationError,
shouldExitForStartupProviderValidationError,
} from './providerValidation.ts'
const originalEnv = {
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
@@ -93,3 +96,45 @@ test('openai missing key error includes recovery guidance and config locations',
expect(message).toContain('Saved startup settings can come from')
expect(message).toContain('.openclaude-profile.json')
})
test('startup provider validation allows interactive recovery', () => {
expect(
shouldExitForStartupProviderValidationError({
args: [],
stdoutIsTTY: true,
}),
).toBe(false)
})
test('startup provider validation stays strict for non-interactive launches', () => {
expect(
shouldExitForStartupProviderValidationError({
args: ['-p', 'hello'],
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldExitForStartupProviderValidationError({
args: ['--print', 'hello'],
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldExitForStartupProviderValidationError({
args: [],
stdoutIsTTY: false,
}),
).toBe(true)
expect(
shouldExitForStartupProviderValidationError({
args: ['--sdk-url', 'ws://127.0.0.1:3000'],
stdoutIsTTY: true,
}),
).toBe(true)
expect(
shouldExitForStartupProviderValidationError({
args: ['--sdk-url=ws://127.0.0.1:3000'],
stdoutIsTTY: true,
}),
).toBe(true)
})

View File

@@ -169,3 +169,44 @@ export async function validateProviderEnvOrExit(
process.exit(1)
}
}
export function shouldExitForStartupProviderValidationError(options: {
args?: string[]
stdoutIsTTY?: boolean
} = {}): boolean {
const args = options.args ?? process.argv.slice(2)
const stdoutIsTTY = options.stdoutIsTTY ?? process.stdout.isTTY
if (!stdoutIsTTY) {
return true
}
return (
args.includes('-p') ||
args.includes('--print') ||
args.includes('--init-only') ||
args.some(arg => arg.startsWith('--sdk-url'))
)
}
export async function validateProviderEnvForStartupOrExit(
env: NodeJS.ProcessEnv = process.env,
options?: {
args?: string[]
stdoutIsTTY?: boolean
},
): Promise<void> {
const error = await getProviderValidationError(env)
if (!error) {
return
}
if (shouldExitForStartupProviderValidationError(options)) {
console.error(error)
process.exit(1)
}
console.error(
`Warning: provider configuration is incomplete.\n${error}\nOpenClaude will continue starting so you can run /provider and repair the saved provider settings.`,
)
}

View File

@@ -0,0 +1,15 @@
import { truncate, truncateToWidth, truncatePathMiddle } from './truncate.js'
describe('truncate utilities', () => {
test('truncate returns empty string for undefined input', () => {
expect(truncate(undefined, 10)).toBe('')
})
test('truncateToWidth returns empty string for undefined input', () => {
expect(truncateToWidth(undefined, 5)).toBe('')
})
test('truncatePathMiddle returns empty string for undefined path', () => {
expect(truncatePathMiddle(undefined, 20)).toBe('')
})
})

View File

@@ -13,10 +13,11 @@ import { getGraphemeSegmenter } from './intl.js'
* @param maxLength Maximum display width of the result in terminal columns (must be > 0)
* @returns The truncated path, or original if it fits within maxLength
*/
export function truncatePathMiddle(path: string, maxLength: number): string {
export function truncatePathMiddle(path: string | undefined, maxLength: number): string {
const safePath = path ?? ''
// No truncation needed
if (stringWidth(path) <= maxLength) {
return path
if (stringWidth(safePath) <= maxLength) {
return safePath
}
// Handle edge case of very small or non-positive maxLength
@@ -26,14 +27,14 @@ export function truncatePathMiddle(path: string, maxLength: number): string {
// Need at least room for "…" + something meaningful
if (maxLength < 5) {
return truncateToWidth(path, maxLength)
return truncateToWidth(safePath, maxLength)
}
// Find the filename (last path segment)
const lastSlash = path.lastIndexOf('/')
const lastSlash = safePath.lastIndexOf('/')
// Include the leading slash in filename for display
const filename = lastSlash >= 0 ? path.slice(lastSlash) : path
const directory = lastSlash >= 0 ? path.slice(0, lastSlash) : ''
const filename = lastSlash >= 0 ? safePath.slice(lastSlash) : safePath
const directory = lastSlash >= 0 ? safePath.slice(0, lastSlash) : ''
const filenameWidth = stringWidth(filename)
// If filename alone is too long, truncate from start
@@ -60,12 +61,13 @@ export function truncatePathMiddle(path: string, maxLength: number): string {
* Splits on grapheme boundaries to avoid breaking emoji or surrogate pairs.
* Appends '…' when truncation occurs.
*/
export function truncateToWidth(text: string, maxWidth: number): string {
if (stringWidth(text) <= maxWidth) return text
export function truncateToWidth(text: string | undefined, maxWidth: number): string {
const safeText = text ?? ''
if (stringWidth(safeText) <= maxWidth) return safeText
if (maxWidth <= 1) return '…'
let width = 0
let result = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
for (const { segment } of getGraphemeSegmenter().segment(safeText)) {
const segWidth = stringWidth(segment)
if (width + segWidth > maxWidth - 1) break
result += segment
@@ -79,10 +81,11 @@ export function truncateToWidth(text: string, maxWidth: number): string {
* Prepends '…' when truncation occurs.
* Width-aware and grapheme-safe.
*/
export function truncateStartToWidth(text: string, maxWidth: number): string {
if (stringWidth(text) <= maxWidth) return text
export function truncateStartToWidth(text: string | undefined, maxWidth: number): string {
const safeText = text ?? ''
if (stringWidth(safeText) <= maxWidth) return safeText
if (maxWidth <= 1) return '…'
const segments = [...getGraphemeSegmenter().segment(text)]
const segments = [...getGraphemeSegmenter().segment(safeText)]
let width = 0
let startIdx = segments.length
for (let i = segments.length - 1; i >= 0; i--) {
@@ -106,14 +109,15 @@ export function truncateStartToWidth(text: string, maxWidth: number): string {
* Width-aware and grapheme-safe.
*/
export function truncateToWidthNoEllipsis(
text: string,
text: string | undefined,
maxWidth: number,
): string {
if (stringWidth(text) <= maxWidth) return text
const safeText = text ?? ''
if (stringWidth(safeText) <= maxWidth) return safeText
if (maxWidth <= 0) return ''
let width = 0
let result = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
for (const { segment } of getGraphemeSegmenter().segment(safeText)) {
const segWidth = stringWidth(segment)
if (width + segWidth > maxWidth) break
result += segment
@@ -133,20 +137,19 @@ export function truncateToWidthNoEllipsis(
*/
export function truncate(
str: string,
str: string | undefined,
maxWidth: number,
singleLine: boolean = false,
): string {
// Undefined or null protection
if (!str) return ''
let result = str
const safeStr = str ?? ''
if (safeStr === '') return ''
let result = safeStr
// If singleLine is true, truncate at first newline
if (singleLine) {
const firstNewline = str.indexOf('\n')
const firstNewline = safeStr.indexOf('\n')
if (firstNewline !== -1) {
result = str.substring(0, firstNewline)
result = safeStr.substring(0, firstNewline)
// Ensure total width including ellipsis doesn't exceed maxWidth
if (stringWidth(result) + 1 > maxWidth) {
return truncateToWidth(result, maxWidth)