mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 添加 Provider Registry、StatusLine、Cache Stats 和其他增强
- providerRegistry: OpenAI 兼容 provider 切换(Cerebras/Groq/DeepSeek/Qwen) - StatusLine: 增强状态栏(缓存命中率、TTL 倒计时、自定义 shell 命令) - cacheStats: 缓存命中率和 token 签名追踪 - ultrareviewPreflight: 代码审查预检服务 - SkillsMenu/filterSkills: 技能菜单过滤增强 - MagicDocs/langfuse prompts: 提示词更新 - claude.ts: API 客户端更新 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
133
src/services/providerRegistry/__tests__/loader.test.ts
Normal file
133
src/services/providerRegistry/__tests__/loader.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
// Must mock log before any import that transitively loads log.ts
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
// bun:bundle must be mocked before imports that use feature()
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// settings.js must be mocked to cut bootstrap chain
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getSettings_DEPRECATED: () => ({}),
|
||||
updateSettingsForSource: () => {},
|
||||
}))
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'provider-loader-test-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
// J1 fix: invalidate the per-process cache between tests so each test starts fresh
|
||||
const { _invalidateProviderCache } = await import('../loader.js')
|
||||
_invalidateProviderCache()
|
||||
})
|
||||
|
||||
describe('loadProviders', () => {
|
||||
test('returns 4 default providers when providers.json does not exist', async () => {
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
expect(providers).toHaveLength(4)
|
||||
expect(providers.map(p => p.id)).toEqual([
|
||||
'cerebras',
|
||||
'groq',
|
||||
'qwen',
|
||||
'deepseek',
|
||||
])
|
||||
})
|
||||
|
||||
test('returns defaults when providers.json is empty', async () => {
|
||||
writeFileSync(join(tmpDir, 'providers.json'), '')
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
expect(providers).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('returns defaults when providers.json is empty array', async () => {
|
||||
writeFileSync(join(tmpDir, 'providers.json'), '[]')
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
expect(providers).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('returns defaults when providers.json is corrupt JSON', async () => {
|
||||
writeFileSync(join(tmpDir, 'providers.json'), '{not valid json')
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
expect(providers).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('returns defaults when providers.json fails schema validation', async () => {
|
||||
writeFileSync(
|
||||
join(tmpDir, 'providers.json'),
|
||||
JSON.stringify([{ id: 123, kind: 'bad-kind', baseUrl: 'not-a-url' }]),
|
||||
)
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
expect(providers).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('merges valid user providers on top of defaults', async () => {
|
||||
const customProvider = {
|
||||
id: 'myendpoint',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://my.api.com/v1',
|
||||
apiKeyEnv: 'MY_API_KEY',
|
||||
defaultModel: 'my-model',
|
||||
compatRule: 'permissive',
|
||||
}
|
||||
writeFileSync(
|
||||
join(tmpDir, 'providers.json'),
|
||||
JSON.stringify([customProvider]),
|
||||
)
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
// 4 defaults + 1 custom = 5
|
||||
expect(providers).toHaveLength(5)
|
||||
expect(providers.find(p => p.id === 'myendpoint')).toMatchObject({
|
||||
baseUrl: 'https://my.api.com/v1',
|
||||
})
|
||||
})
|
||||
|
||||
test('user provider with same id as default replaces the default', async () => {
|
||||
const overrideCerebras = {
|
||||
id: 'cerebras',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://custom-cerebras.example.com/v1',
|
||||
apiKeyEnv: 'CEREBRAS_API_KEY',
|
||||
defaultModel: 'llama-3.3-70b',
|
||||
compatRule: 'cerebras',
|
||||
}
|
||||
writeFileSync(
|
||||
join(tmpDir, 'providers.json'),
|
||||
JSON.stringify([overrideCerebras]),
|
||||
)
|
||||
const { loadProviders } = await import('../loader.js')
|
||||
const providers = loadProviders()
|
||||
// Still 4 providers (cerebras replaced, not added)
|
||||
expect(providers).toHaveLength(4)
|
||||
const cerebras = providers.find(p => p.id === 'cerebras')
|
||||
expect(cerebras?.baseUrl).toBe('https://custom-cerebras.example.com/v1')
|
||||
})
|
||||
|
||||
test('findProvider returns undefined for unknown id', async () => {
|
||||
const { findProvider, DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = findProvider('nonexistent', DEFAULT_PROVIDERS)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('findProvider returns correct provider for known id', async () => {
|
||||
const { findProvider, DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const deepseek = findProvider('deepseek', DEFAULT_PROVIDERS)
|
||||
expect(deepseek?.baseUrl).toBe('https://api.deepseek.com/v1')
|
||||
expect(deepseek?.compatRule).toBe('deepseek')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import {
|
||||
COMPAT_PROFILES,
|
||||
applyCompatRule,
|
||||
getDeepSeekReasoningMode,
|
||||
} from '../providerCompatMatrix.js'
|
||||
|
||||
describe('COMPAT_PROFILES', () => {
|
||||
test('cerebras does not support stream_options', () => {
|
||||
expect(COMPAT_PROFILES['cerebras'].supportsStreamUsageOption).toBe(false)
|
||||
})
|
||||
|
||||
test('cerebras does not support thinking field', () => {
|
||||
expect(COMPAT_PROFILES['cerebras'].supportsThinkingField).toBe(false)
|
||||
})
|
||||
|
||||
test('groq strips reasoning_content', () => {
|
||||
expect(COMPAT_PROFILES['groq'].reasoningContentEcho).toBe('strip')
|
||||
})
|
||||
|
||||
test('deepseek preserves reasoning_content', () => {
|
||||
expect(COMPAT_PROFILES['deepseek'].reasoningContentEcho).toBe(
|
||||
'always-preserve',
|
||||
)
|
||||
})
|
||||
|
||||
test('deepseek supports thinking field', () => {
|
||||
expect(COMPAT_PROFILES['deepseek'].supportsThinkingField).toBe(true)
|
||||
})
|
||||
|
||||
test('strict-openai strips stream_options', () => {
|
||||
expect(COMPAT_PROFILES['strict-openai'].supportsStreamUsageOption).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('permissive allows all fields', () => {
|
||||
expect(COMPAT_PROFILES['permissive'].supportsStreamUsageOption).toBe(true)
|
||||
expect(COMPAT_PROFILES['permissive'].supportsThinkingField).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCompatRule - stream_options stripping', () => {
|
||||
test('strips stream_options.include_usage for cerebras', () => {
|
||||
const body = {
|
||||
model: 'llama-3.3-70b',
|
||||
messages: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
const result = applyCompatRule(body, 'cerebras')
|
||||
expect(result['stream_options']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('strips stream_options for strict-openai', () => {
|
||||
const body = {
|
||||
messages: [],
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
const result = applyCompatRule(body, 'strict-openai')
|
||||
expect(result['stream_options']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('preserves stream_options for deepseek', () => {
|
||||
const body = {
|
||||
messages: [],
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
const result = applyCompatRule(body, 'deepseek')
|
||||
expect(result['stream_options']).toEqual({ include_usage: true })
|
||||
})
|
||||
|
||||
test('preserves stream_options for permissive', () => {
|
||||
const body = {
|
||||
messages: [],
|
||||
stream_options: { include_usage: true, other_field: 'x' },
|
||||
}
|
||||
const result = applyCompatRule(body, 'permissive')
|
||||
expect(result['stream_options']).toEqual({
|
||||
include_usage: true,
|
||||
other_field: 'x',
|
||||
})
|
||||
})
|
||||
|
||||
test('does not mutate input body', () => {
|
||||
const body = {
|
||||
messages: [],
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
applyCompatRule(body, 'groq')
|
||||
// Input must be unchanged
|
||||
expect(body['stream_options']).toEqual({ include_usage: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCompatRule - thinking field stripping', () => {
|
||||
test('strips thinking field from messages for cerebras', () => {
|
||||
const body = {
|
||||
messages: [{ role: 'user', content: 'hi', thinking: { budget: 1000 } }],
|
||||
}
|
||||
const result = applyCompatRule(body, 'cerebras')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['thinking']).toBeUndefined()
|
||||
expect(msgs[0]!['content']).toBe('hi')
|
||||
})
|
||||
|
||||
test('preserves thinking field for deepseek', () => {
|
||||
const body = {
|
||||
messages: [{ role: 'user', content: 'hi', thinking: { budget: 1000 } }],
|
||||
}
|
||||
const result = applyCompatRule(body, 'deepseek')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['thinking']).toEqual({ budget: 1000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCompatRule - DeepSeek reasoning_content three modes', () => {
|
||||
test('thinking-only mode: strips reasoning_content for strict-openai (non-deepseek)', () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'answer', reasoning_content: 'thoughts' },
|
||||
],
|
||||
}
|
||||
const result = applyCompatRule(body, 'strict-openai')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['reasoning_content']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('thinking-only mode: preserves reasoning_content for deepseek', () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'answer', reasoning_content: 'thoughts' },
|
||||
],
|
||||
}
|
||||
const result = applyCompatRule(body, 'deepseek')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['reasoning_content']).toBe('thoughts')
|
||||
})
|
||||
|
||||
test('thinking+tools mode: preserves reasoning_content for deepseek', () => {
|
||||
const body = {
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
reasoning_content: 'deep thoughts',
|
||||
tool_calls: [{ id: 'call_1', function: { name: 'search' } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const result = applyCompatRule(body, 'deepseek')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['reasoning_content']).toBe('deep thoughts')
|
||||
})
|
||||
|
||||
test('permissive with non-thinking model strips reasoning_content', () => {
|
||||
const body = {
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'hi', reasoning_content: 'unused' },
|
||||
],
|
||||
}
|
||||
const result = applyCompatRule(body, 'permissive')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['reasoning_content']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('permissive with thinking model preserves reasoning_content', () => {
|
||||
const body = {
|
||||
model: 'deepseek-reasoner',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'hi', reasoning_content: 'thoughts' },
|
||||
],
|
||||
}
|
||||
const result = applyCompatRule(body, 'permissive')
|
||||
const msgs = result['messages'] as Record<string, unknown>[]
|
||||
expect(msgs[0]!['reasoning_content']).toBe('thoughts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDeepSeekReasoningMode', () => {
|
||||
test('thinking-only: has reasoning_content, no tool_calls', () => {
|
||||
const msg = { reasoning_content: 'thoughts', content: 'answer' }
|
||||
expect(getDeepSeekReasoningMode(msg)).toBe('thinking-only')
|
||||
})
|
||||
|
||||
test('thinking+tools: has both reasoning_content and tool_calls', () => {
|
||||
const msg = {
|
||||
reasoning_content: 'deep thoughts',
|
||||
tool_calls: [{ id: 'call_1' }],
|
||||
}
|
||||
expect(getDeepSeekReasoningMode(msg)).toBe('thinking+tools')
|
||||
})
|
||||
|
||||
test('normal: no reasoning_content', () => {
|
||||
const msg = { content: 'plain answer' }
|
||||
expect(getDeepSeekReasoningMode(msg)).toBe('normal')
|
||||
})
|
||||
|
||||
test('normal: empty tool_calls array with no reasoning_content', () => {
|
||||
const msg = { content: 'plain', tool_calls: [] }
|
||||
expect(getDeepSeekReasoningMode(msg)).toBe('normal')
|
||||
})
|
||||
})
|
||||
129
src/services/providerRegistry/__tests__/switcher.test.ts
Normal file
129
src/services/providerRegistry/__tests__/switcher.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getSettings_DEPRECATED: () => ({}),
|
||||
updateSettingsForSource: () => {},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean OpenAI env vars before each test
|
||||
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
||||
delete process.env['OPENAI_API_KEY']
|
||||
delete process.env['OPENAI_BASE_URL']
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
delete process.env['CEREBRAS_API_KEY']
|
||||
delete process.env['GROQ_API_KEY']
|
||||
delete process.env['DASHSCOPE_API_KEY']
|
||||
delete process.env['DEEPSEEK_API_KEY']
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['CLAUDE_CODE_USE_OPENAI']
|
||||
delete process.env['OPENAI_API_KEY']
|
||||
delete process.env['OPENAI_BASE_URL']
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
})
|
||||
|
||||
describe('switchProvider', () => {
|
||||
test('switching to cerebras returns correct env vars', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('cerebras', DEFAULT_PROVIDERS)
|
||||
expect(result.env['CLAUDE_CODE_USE_OPENAI']).toBe('1')
|
||||
expect(result.env['OPENAI_BASE_URL']).toBe('https://api.cerebras.ai/v1')
|
||||
expect(result.env['OPENAI_MODEL']).toBe('llama-3.3-70b')
|
||||
expect(result.provider.id).toBe('cerebras')
|
||||
})
|
||||
|
||||
test('switching to groq returns correct env vars', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('groq', DEFAULT_PROVIDERS)
|
||||
expect(result.env['OPENAI_BASE_URL']).toBe('https://api.groq.com/openai/v1')
|
||||
expect(result.env['OPENAI_MODEL']).toBe('llama-3.3-70b-versatile')
|
||||
})
|
||||
|
||||
test('switching to qwen returns correct env vars', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('qwen', DEFAULT_PROVIDERS)
|
||||
expect(result.env['OPENAI_BASE_URL']).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
)
|
||||
expect(result.env['OPENAI_MODEL']).toBe('qwen-max')
|
||||
})
|
||||
|
||||
test('switching to deepseek returns correct env vars', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('deepseek', DEFAULT_PROVIDERS)
|
||||
expect(result.env['OPENAI_BASE_URL']).toBe('https://api.deepseek.com/v1')
|
||||
expect(result.env['OPENAI_MODEL']).toBe('deepseek-chat')
|
||||
})
|
||||
|
||||
test('throws for non-existent provider id', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
expect(() => switchProvider('nonexistent', DEFAULT_PROVIDERS)).toThrow(
|
||||
'provider "nonexistent" not found',
|
||||
)
|
||||
})
|
||||
|
||||
test('warns when provider API key env var is not set', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('cerebras', DEFAULT_PROVIDERS)
|
||||
expect(result.warnings.length).toBeGreaterThan(0)
|
||||
expect(result.warnings[0]).toContain('CEREBRAS_API_KEY')
|
||||
})
|
||||
|
||||
test('no warning when provider API key env var is set', async () => {
|
||||
process.env['GROQ_API_KEY'] = 'test-key'
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('groq', DEFAULT_PROVIDERS)
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
delete process.env['GROQ_API_KEY']
|
||||
})
|
||||
|
||||
test('does not mutate process.env', async () => {
|
||||
const { switchProvider } = await import('../switcher.js')
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const before = process.env['OPENAI_BASE_URL']
|
||||
switchProvider('cerebras', DEFAULT_PROVIDERS)
|
||||
expect(process.env['OPENAI_BASE_URL']).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildShellExportBlock', () => {
|
||||
test('produces correct shell export lines for cerebras', async () => {
|
||||
const { switchProvider, buildShellExportBlock } = await import(
|
||||
'../switcher.js'
|
||||
)
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('cerebras', DEFAULT_PROVIDERS)
|
||||
const block = buildShellExportBlock(result)
|
||||
expect(block).toContain('export CLAUDE_CODE_USE_OPENAI=1')
|
||||
expect(block).toContain('export OPENAI_BASE_URL=https://api.cerebras.ai/v1')
|
||||
expect(block).toContain('export OPENAI_API_KEY=$CEREBRAS_API_KEY')
|
||||
expect(block).toContain('export OPENAI_MODEL=llama-3.3-70b')
|
||||
})
|
||||
|
||||
test('api key line uses variable reference not literal value', async () => {
|
||||
process.env['DEEPSEEK_API_KEY'] = 'sk-secret-key'
|
||||
const { switchProvider, buildShellExportBlock } = await import(
|
||||
'../switcher.js'
|
||||
)
|
||||
const { DEFAULT_PROVIDERS } = await import('../loader.js')
|
||||
const result = switchProvider('deepseek', DEFAULT_PROVIDERS)
|
||||
const block = buildShellExportBlock(result)
|
||||
// Must NOT contain the literal key value
|
||||
expect(block).not.toContain('sk-secret-key')
|
||||
// Must use variable reference
|
||||
expect(block).toContain('$DEEPSEEK_API_KEY')
|
||||
delete process.env['DEEPSEEK_API_KEY']
|
||||
})
|
||||
})
|
||||
246
src/services/providerRegistry/loader.ts
Normal file
246
src/services/providerRegistry/loader.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { ProvidersFileSchema, type ProviderConfig } from './types.js'
|
||||
|
||||
/**
|
||||
* The four built-in OpenAI-compat providers.
|
||||
*
|
||||
* These are used when providers.json is absent or contains no entries.
|
||||
* User-defined providers in ~/.claude/providers.json are merged on top
|
||||
* (they replace a built-in with the same id).
|
||||
*/
|
||||
export const DEFAULT_PROVIDERS: ProviderConfig[] = [
|
||||
{
|
||||
id: 'cerebras',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://api.cerebras.ai/v1',
|
||||
apiKeyEnv: 'CEREBRAS_API_KEY',
|
||||
defaultModel: 'llama-3.3-70b',
|
||||
compatRule: 'cerebras',
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://api.groq.com/openai/v1',
|
||||
apiKeyEnv: 'GROQ_API_KEY',
|
||||
defaultModel: 'llama-3.3-70b-versatile',
|
||||
compatRule: 'groq',
|
||||
},
|
||||
{
|
||||
id: 'qwen',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
apiKeyEnv: 'DASHSCOPE_API_KEY',
|
||||
defaultModel: 'qwen-max',
|
||||
compatRule: 'strict-openai',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
kind: 'openai-compat',
|
||||
baseUrl: 'https://api.deepseek.com/v1',
|
||||
apiKeyEnv: 'DEEPSEEK_API_KEY',
|
||||
defaultModel: 'deepseek-chat',
|
||||
compatRule: 'deepseek',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Returns the path to the providers.json file in the Claude config directory.
|
||||
*/
|
||||
export function getProvidersFilePath(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'providers.json')
|
||||
}
|
||||
|
||||
// ── J1: per-process memoization with stale-on-invalidate ─────────────────────
|
||||
|
||||
let _cachedProviders: ProviderConfig[] | null = null
|
||||
|
||||
/** Invalidate the in-process provider cache (called after saveProviders). */
|
||||
export function _invalidateProviderCache(): void {
|
||||
_cachedProviders = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load provider configurations.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Start with DEFAULT_PROVIDERS.
|
||||
* 2. If ~/.claude/providers.json exists, parse and validate it with Zod.
|
||||
* - Valid entries replace defaults with matching id; new ids are appended.
|
||||
* - Corrupt/invalid file: log warning, return defaults only.
|
||||
* 3. Empty providers.json: return defaults.
|
||||
*
|
||||
* A1 fix: returns load diagnostics so callers (ProviderView) can surface errors.
|
||||
* J1 fix: memoized per-process; invalidated after saveProviders().
|
||||
*
|
||||
* This function never throws — corrupt files produce a warning + fallback.
|
||||
*/
|
||||
export function loadProviders(): ProviderConfig[] {
|
||||
// J1: return cached result if available (prevents repeated disk reads on findProvider)
|
||||
if (_cachedProviders !== null) return _cachedProviders
|
||||
|
||||
const result = _loadProvidersInternal()
|
||||
_cachedProviders = result.providers
|
||||
return result.providers
|
||||
}
|
||||
|
||||
/**
|
||||
* Load providers with diagnostic information.
|
||||
* Returns { providers, error? } — callers can surface the error to the UI.
|
||||
* A1 fix: exposes parse errors to UI layer instead of only logError.
|
||||
*/
|
||||
export function loadProvidersWithDiagnostic(): {
|
||||
providers: ProviderConfig[]
|
||||
error?: string
|
||||
} {
|
||||
const result = _loadProvidersInternal()
|
||||
_cachedProviders = result.providers
|
||||
return result
|
||||
}
|
||||
|
||||
function _loadProvidersInternal(): {
|
||||
providers: ProviderConfig[]
|
||||
error?: string
|
||||
} {
|
||||
const filePath = getProvidersFilePath()
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return { providers: [...DEFAULT_PROVIDERS] }
|
||||
}
|
||||
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf-8')
|
||||
} catch (err: unknown) {
|
||||
const msg = `loadProviders: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`
|
||||
logError(new Error(msg))
|
||||
return { providers: [...DEFAULT_PROVIDERS], error: msg }
|
||||
}
|
||||
|
||||
// Empty file → return defaults
|
||||
if (!raw.trim()) {
|
||||
return { providers: [...DEFAULT_PROVIDERS] }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
const msg = `loadProviders: ${filePath} is not valid JSON. Using default providers.`
|
||||
logError(new Error(msg))
|
||||
return { providers: [...DEFAULT_PROVIDERS], error: msg }
|
||||
}
|
||||
|
||||
const result = ProvidersFileSchema.safeParse(parsed)
|
||||
if (!result.success) {
|
||||
const msg = `loadProviders: ${filePath} failed schema validation: ${result.error.message}. Using default providers.`
|
||||
logError(new Error(msg))
|
||||
return { providers: [...DEFAULT_PROVIDERS], error: msg }
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
return { providers: [...DEFAULT_PROVIDERS] }
|
||||
}
|
||||
|
||||
// Merge: user entries override defaults with same id; new ids are appended.
|
||||
const merged = new Map<string, ProviderConfig>()
|
||||
for (const p of DEFAULT_PROVIDERS) {
|
||||
merged.set(p.id, p)
|
||||
}
|
||||
for (const p of result.data) {
|
||||
merged.set(p.id, p)
|
||||
}
|
||||
|
||||
return { providers: Array.from(merged.values()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a provider by id in the loaded list. Returns undefined if not found.
|
||||
*/
|
||||
export function findProvider(
|
||||
id: string,
|
||||
providers?: ProviderConfig[],
|
||||
): ProviderConfig | undefined {
|
||||
return (providers ?? loadProviders()).find(p => p.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-equal comparison for ProviderConfig objects, key-order independent.
|
||||
* E4 fix: replaces JSON.stringify comparison which is key-order sensitive.
|
||||
*/
|
||||
function providerConfigEqual(a: ProviderConfig, b: ProviderConfig): boolean {
|
||||
const keysA = Object.keys(a).sort()
|
||||
const keysB = Object.keys(b).sort()
|
||||
if (keysA.length !== keysB.length) return false
|
||||
for (const k of keysA) {
|
||||
if (a[k as keyof ProviderConfig] !== b[k as keyof ProviderConfig])
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Write additional providers to ~/.claude/providers.json.
|
||||
*
|
||||
* Only writes providers that are NOT already in DEFAULT_PROVIDERS (or the
|
||||
* existing file). If a provider with the same id exists, it is replaced.
|
||||
*
|
||||
* C3 fix: uses atomic tmp+rename write.
|
||||
* E4 fix: uses key-order-independent deep equal for default comparison.
|
||||
* J1 fix: invalidates cache after write.
|
||||
*
|
||||
* Returns the final merged list that was written.
|
||||
*/
|
||||
export function saveProviders(providers: ProviderConfig[]): ProviderConfig[] {
|
||||
const filePath = getProvidersFilePath()
|
||||
|
||||
// Build merged list (providers override defaults by id)
|
||||
const merged = new Map<string, ProviderConfig>()
|
||||
for (const p of DEFAULT_PROVIDERS) {
|
||||
merged.set(p.id, p)
|
||||
}
|
||||
for (const p of providers) {
|
||||
merged.set(p.id, p)
|
||||
}
|
||||
|
||||
// Only persist non-default providers (defaults are always built in)
|
||||
const toWrite: ProviderConfig[] = []
|
||||
for (const [id, p] of merged) {
|
||||
const isDefault = DEFAULT_PROVIDERS.some(d => d.id === id)
|
||||
if (!isDefault) {
|
||||
toWrite.push(p)
|
||||
} else {
|
||||
// E4: If user overrode a default, persist the override (key-order-independent compare)
|
||||
const defaultEntry = DEFAULT_PROVIDERS.find(d => d.id === id)
|
||||
if (defaultEntry && !providerConfigEqual(defaultEntry, p)) {
|
||||
toWrite.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C3: atomic write — tmp file + rename prevents lost-update on concurrent save
|
||||
const tmpPath = join(
|
||||
tmpdir(),
|
||||
`.providers-${randomBytes(8).toString('hex')}.tmp`,
|
||||
)
|
||||
try {
|
||||
writeFileSync(tmpPath, JSON.stringify(toWrite, null, 2), 'utf-8')
|
||||
renameSync(tmpPath, filePath)
|
||||
} catch (err) {
|
||||
try {
|
||||
renameSync(tmpPath, tmpPath + '.cleanup')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// J1: invalidate cache so next loadProviders() reads fresh data
|
||||
_invalidateProviderCache()
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
179
src/services/providerRegistry/providerCompatMatrix.ts
Normal file
179
src/services/providerRegistry/providerCompatMatrix.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { CompatRule } from './types.js'
|
||||
|
||||
/**
|
||||
* Per-provider OpenAI-compat field whitelist.
|
||||
*
|
||||
* Each profile describes what an endpoint actually accepts so we can strip
|
||||
* fields that would cause a strict endpoint to reject the request.
|
||||
*/
|
||||
export interface CompatProfile {
|
||||
/**
|
||||
* Whether the server accepts stream_options.include_usage in chat completions.
|
||||
* Strict endpoints (Cerebras, Qwen) reject unknown top-level keys.
|
||||
*/
|
||||
supportsStreamUsageOption: boolean
|
||||
|
||||
/**
|
||||
* Whether the server accepts a custom 'thinking' field in messages.
|
||||
* Only permissive or DeepSeek-thinking endpoints accept this.
|
||||
*/
|
||||
supportsThinkingField: boolean
|
||||
|
||||
/**
|
||||
* How to handle reasoning_content in roundtrips.
|
||||
*
|
||||
* DeepSeek has three modes:
|
||||
* - thinking-only: model returns reasoning_content, no tools
|
||||
* - thinking+tools: model returns both reasoning_content and tool calls
|
||||
* - normal: model returns neither
|
||||
*
|
||||
* 'always-preserve': echo back (DeepSeek thinking+tools roundtrip)
|
||||
* 'drop-on-non-thinking': remove unless current model is thinking variant
|
||||
* 'strip': remove always (safe default for strict endpoints)
|
||||
*/
|
||||
reasoningContentEcho: 'always-preserve' | 'drop-on-non-thinking' | 'strip'
|
||||
|
||||
/**
|
||||
* Tool call schema flavor supported by the endpoint.
|
||||
* 'openai-v2' = standard OpenAI function-calling schema
|
||||
*/
|
||||
toolCallFormat: 'openai-v2'
|
||||
}
|
||||
|
||||
export const COMPAT_PROFILES: Record<CompatRule, CompatProfile> = {
|
||||
cerebras: {
|
||||
supportsStreamUsageOption: false,
|
||||
supportsThinkingField: false,
|
||||
reasoningContentEcho: 'strip',
|
||||
toolCallFormat: 'openai-v2',
|
||||
},
|
||||
groq: {
|
||||
supportsStreamUsageOption: false,
|
||||
supportsThinkingField: false,
|
||||
reasoningContentEcho: 'strip',
|
||||
toolCallFormat: 'openai-v2',
|
||||
},
|
||||
deepseek: {
|
||||
// DeepSeek-reasoner supports reasoning_content and the thinking field.
|
||||
// For normal deepseek-chat, thinking field is ignored rather than rejected.
|
||||
supportsStreamUsageOption: true,
|
||||
supportsThinkingField: true,
|
||||
reasoningContentEcho: 'always-preserve',
|
||||
toolCallFormat: 'openai-v2',
|
||||
},
|
||||
'strict-openai': {
|
||||
supportsStreamUsageOption: false,
|
||||
supportsThinkingField: false,
|
||||
reasoningContentEcho: 'strip',
|
||||
toolCallFormat: 'openai-v2',
|
||||
},
|
||||
permissive: {
|
||||
supportsStreamUsageOption: true,
|
||||
supportsThinkingField: true,
|
||||
reasoningContentEcho: 'drop-on-non-thinking',
|
||||
toolCallFormat: 'openai-v2',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the DeepSeek reasoning mode based on presence of reasoning_content
|
||||
* and tool_calls in the assistant message.
|
||||
*
|
||||
* DeepSeek thinking-only: has reasoning_content, no tool_calls
|
||||
* DeepSeek thinking+tools: has reasoning_content AND tool_calls
|
||||
* DeepSeek normal: no reasoning_content
|
||||
*/
|
||||
export function getDeepSeekReasoningMode(
|
||||
assistantMessage: Record<string, unknown>,
|
||||
): 'thinking-only' | 'thinking+tools' | 'normal' {
|
||||
const hasReasoning = Boolean(assistantMessage['reasoning_content'])
|
||||
const toolCalls = assistantMessage['tool_calls']
|
||||
const hasTools = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
|
||||
if (hasReasoning && hasTools) return 'thinking+tools'
|
||||
if (hasReasoning) return 'thinking-only'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a compat rule to an outgoing request body, dropping fields the
|
||||
* target endpoint won't accept. Returns a new object (immutable).
|
||||
*
|
||||
* This is a pure function: it does not mutate the input body.
|
||||
*/
|
||||
export function applyCompatRule(
|
||||
body: Record<string, unknown>,
|
||||
rule: CompatRule,
|
||||
): Record<string, unknown> {
|
||||
const profile = COMPAT_PROFILES[rule]
|
||||
const result: Record<string, unknown> = { ...body }
|
||||
|
||||
// Strip stream_options.include_usage if endpoint doesn't support it
|
||||
if (!profile.supportsStreamUsageOption) {
|
||||
const streamOptions = result['stream_options']
|
||||
if (
|
||||
streamOptions !== null &&
|
||||
typeof streamOptions === 'object' &&
|
||||
!Array.isArray(streamOptions)
|
||||
) {
|
||||
const { include_usage: _dropped, ...rest } = streamOptions as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (Object.keys(rest).length === 0) {
|
||||
delete result['stream_options']
|
||||
} else {
|
||||
result['stream_options'] = rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip 'thinking' field from messages if endpoint doesn't support it
|
||||
if (!profile.supportsThinkingField && Array.isArray(result['messages'])) {
|
||||
result['messages'] = (result['messages'] as Record<string, unknown>[]).map(
|
||||
msg => {
|
||||
if ('thinking' in msg) {
|
||||
const { thinking: _dropped, ...rest } = msg
|
||||
return rest
|
||||
}
|
||||
return msg
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Handle reasoning_content echo policy
|
||||
if (
|
||||
profile.reasoningContentEcho === 'strip' &&
|
||||
Array.isArray(result['messages'])
|
||||
) {
|
||||
result['messages'] = (result['messages'] as Record<string, unknown>[]).map(
|
||||
msg => {
|
||||
if ('reasoning_content' in msg) {
|
||||
const { reasoning_content: _dropped, ...rest } = msg
|
||||
return rest
|
||||
}
|
||||
return msg
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// For 'drop-on-non-thinking': strip reasoning_content unless model name
|
||||
// indicates a thinking variant (contains 'reason' or 'think' in model string)
|
||||
if (profile.reasoningContentEcho === 'drop-on-non-thinking') {
|
||||
const model = typeof result['model'] === 'string' ? result['model'] : ''
|
||||
const isThinkingModel = /reason|think/i.test(model)
|
||||
if (!isThinkingModel && Array.isArray(result['messages'])) {
|
||||
result['messages'] = (
|
||||
result['messages'] as Record<string, unknown>[]
|
||||
).map(msg => {
|
||||
if ('reasoning_content' in msg) {
|
||||
const { reasoning_content: _dropped, ...rest } = msg
|
||||
return rest
|
||||
}
|
||||
return msg
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
111
src/services/providerRegistry/switcher.ts
Normal file
111
src/services/providerRegistry/switcher.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { findProvider, loadProviders } from './loader.js'
|
||||
import type { ProviderConfig } from './types.js'
|
||||
|
||||
export interface SwitchProviderResult {
|
||||
/**
|
||||
* Environment variables to set before the next session.
|
||||
* This is informational — the caller must NOT mutate process.env.
|
||||
* The user copies these into their shell profile.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
|
||||
/**
|
||||
* Human-readable warnings (e.g. missing API key in current env).
|
||||
* Non-fatal: the user can still configure the provider.
|
||||
*/
|
||||
warnings: string[]
|
||||
|
||||
/**
|
||||
* The resolved provider config used for this switch.
|
||||
*/
|
||||
provider: ProviderConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the environment variables needed to activate an OpenAI-compat provider.
|
||||
*
|
||||
* Design constraints (from plan):
|
||||
* - Pure functional: does NOT mutate process.env
|
||||
* - Calls assertNoAnthropicEnvForOpenAI() at the top to warn on credential
|
||||
* confusion (ANTHROPIC_API_KEY + OPENAI-compat mode both set)
|
||||
* - Returns shell export commands the user can paste into their profile
|
||||
* - Restart required for the env vars to take effect (OpenAI client is cached)
|
||||
*
|
||||
* @param id - Provider id (e.g. 'cerebras', 'groq', 'deepseek', 'qwen')
|
||||
* @param providers - Optional pre-loaded list (defaults to loadProviders())
|
||||
* @throws {Error} if provider id is not found
|
||||
*/
|
||||
export function switchProvider(
|
||||
id: string,
|
||||
providers?: ProviderConfig[],
|
||||
): SwitchProviderResult {
|
||||
const list = providers ?? loadProviders()
|
||||
const found = findProvider(id, list)
|
||||
|
||||
if (!found) {
|
||||
const ids = list.map(p => p.id).join(', ')
|
||||
throw new Error(
|
||||
`switchProvider: provider "${id}" not found. Available: ${ids}`,
|
||||
)
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
CLAUDE_CODE_USE_OPENAI: '1',
|
||||
OPENAI_BASE_URL: found.baseUrl,
|
||||
OPENAI_MODEL: found.defaultModel,
|
||||
// The value is the env var name that holds the key, not the key itself.
|
||||
// Shell snippet: export OPENAI_API_KEY=$CEREBRAS_API_KEY
|
||||
// We return the recommended export, but the actual value depends on user env.
|
||||
}
|
||||
|
||||
// Include the api key env var name so callers can construct the shell snippet.
|
||||
// We do NOT read process.env[found.apiKeyEnv] to avoid leaking the key.
|
||||
const warnings: string[] = []
|
||||
|
||||
// G3: include ANTHROPIC_API_KEY conflict warning in result.warnings (not just logError)
|
||||
// so that the Ink view (/providers use) can render it to the user rather than losing it
|
||||
// in a side-channel stderr log.
|
||||
const hasOpenAIMode =
|
||||
process.env['CLAUDE_CODE_USE_OPENAI'] === '1' ||
|
||||
Boolean(process.env['OPENAI_API_KEY'])
|
||||
const hasAnthropicKey = Boolean(process.env['ANTHROPIC_API_KEY'])
|
||||
if (hasOpenAIMode && hasAnthropicKey) {
|
||||
warnings.push(
|
||||
'Both ANTHROPIC_API_KEY and OpenAI-compat mode are set. ' +
|
||||
'ANTHROPIC_API_KEY is for Anthropic workspace endpoints (/v1/agents, /v1/vaults). ' +
|
||||
'OpenAI-compat mode routes /v1/messages to a third-party provider. ' +
|
||||
'These are separate planes — verify this is intentional.',
|
||||
)
|
||||
}
|
||||
|
||||
if (!process.env[found.apiKeyEnv]) {
|
||||
warnings.push(
|
||||
`${found.apiKeyEnv} is not set in the current environment. ` +
|
||||
`Set it before starting Claude Code: export ${found.apiKeyEnv}=<your-api-key>`,
|
||||
)
|
||||
}
|
||||
|
||||
return { env, warnings, provider: found }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shell export block to display to the user.
|
||||
*
|
||||
* Example output:
|
||||
* export CLAUDE_CODE_USE_OPENAI=1
|
||||
* export OPENAI_BASE_URL=https://api.cerebras.ai/v1
|
||||
* export OPENAI_API_KEY=$CEREBRAS_API_KEY
|
||||
* export OPENAI_MODEL=llama-3.3-70b
|
||||
*
|
||||
* The API key line uses a variable reference so the actual key is never echoed.
|
||||
*/
|
||||
export function buildShellExportBlock(result: SwitchProviderResult): string {
|
||||
const { env, provider } = result
|
||||
const lines: string[] = [
|
||||
`export CLAUDE_CODE_USE_OPENAI=${env['CLAUDE_CODE_USE_OPENAI'] ?? '1'}`,
|
||||
`export OPENAI_BASE_URL=${env['OPENAI_BASE_URL'] ?? provider.baseUrl}`,
|
||||
`export OPENAI_API_KEY=$${provider.apiKeyEnv}`,
|
||||
`export OPENAI_MODEL=${env['OPENAI_MODEL'] ?? provider.defaultModel}`,
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
51
src/services/providerRegistry/types.ts
Normal file
51
src/services/providerRegistry/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Compat rule identifiers. Each maps to a CompatProfile in providerCompatMatrix.ts.
|
||||
*/
|
||||
export const CompatRuleSchema = z.enum([
|
||||
'cerebras',
|
||||
'groq',
|
||||
'deepseek',
|
||||
'strict-openai',
|
||||
'permissive',
|
||||
])
|
||||
|
||||
export type CompatRule = z.infer<typeof CompatRuleSchema>
|
||||
|
||||
/**
|
||||
* The only supported provider kind for PR-2. Future PR-3+ may add 'oauth', 'bedrock-compat', etc.
|
||||
*/
|
||||
export const ProviderKindSchema = z.literal('openai-compat')
|
||||
export type ProviderKind = z.infer<typeof ProviderKindSchema>
|
||||
|
||||
/**
|
||||
* Zod schema for a single provider configuration entry.
|
||||
*
|
||||
* Rules:
|
||||
* - id: kebab-case identifier used in /provider use <id>
|
||||
* - kind: only 'openai-compat' in PR-2
|
||||
* - baseUrl: full base URL including /v1 suffix if needed
|
||||
* - apiKeyEnv: name of the env var that holds the API key
|
||||
* - defaultModel: model string passed as OPENAI_MODEL
|
||||
* - compatRule: selects CompatProfile from providerCompatMatrix
|
||||
*/
|
||||
export const ProviderConfigSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9-]+$/, 'id must be kebab-case'),
|
||||
kind: ProviderKindSchema,
|
||||
baseUrl: z.string().url(),
|
||||
apiKeyEnv: z.string().min(1),
|
||||
defaultModel: z.string().min(1),
|
||||
compatRule: CompatRuleSchema,
|
||||
})
|
||||
|
||||
export type ProviderConfig = z.infer<typeof ProviderConfigSchema>
|
||||
|
||||
/**
|
||||
* Schema for the entire ~/.claude/providers.json file.
|
||||
* Top-level must be an array of ProviderConfig.
|
||||
*/
|
||||
export const ProvidersFileSchema = z.array(ProviderConfigSchema)
|
||||
Reference in New Issue
Block a user