mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
* refactor: 创建 @anthropic-ai/model-provider 包骨架与类型定义
- 新建 workspace 包 packages/@anthropic-ai/model-provider
- 定义 ModelProviderHooks 接口(依赖注入:分析、成本、日志等)
- 定义 ClientFactories 接口(Anthropic/OpenAI/Gemini/Grok 客户端工厂)
- 搬入核心类型:Message 体系、NonNullableUsage、EMPTY_USAGE、SystemPrompt、错误常量
- 主项目 src/types/message.ts 等改为 re-export,保持向后兼容
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: 提升 OpenAI 转换器和模型映射到 model-provider 包
- 搬入 OpenAI 消息转换(convertMessages)、工具转换(convertTools)、流适配(streamAdapter)
- 搬入 OpenAI 和 Grok 模型映射(resolveOpenAIModel、resolveGrokModel)
- 主项目文件改为 thin re-export proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: 搬入 Gemini 兼容层到 model-provider 包
- 搬入 Gemini 类型定义、消息转换、工具转换、流适配、模型映射
- 主项目 gemini/ 目录下文件改为 thin re-export proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: 搬入 errorUtils 并迁移消费者导入到 model-provider
- 搬入 formatAPIError、extractConnectionErrorDetails 等 errorUtils
- 迁移 10 个消费者文件直接从 @anthropic-ai/model-provider 导入
- 更新 emptyUsage、sdkUtilityTypes、systemPromptType 为 re-export proxy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: 添加 agent-loop 绘图
* Revert "feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)"
This reverts commit e458d6391d.
* docs: 添加简化版 agent loop
* fix: 修复 n 快捷键导致关闭的问题
* fix: 修复 node 下 ws 没打包问题
* docs: 修复链接
* test: 添加测试支持
* fix: 修复类型问题(#267) (#271)
* fix: 修复 Bun 的 polyfill 问题
* fix: 类型修复完成
* feat: 统一所有包的类型文件
* fix: 修复构建问题
* test: 修复类型校验 (#279)
* fix: 修复 Bun 的 polyfill 问题
* fix: 类型修复完成
* feat: 统一所有包的类型文件
* fix: 修复构建问题
* fix(remote-control): harden self-hosted session flows (#278)
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
* docs: update contributors
* build: 新增 vite 构建流程
* feat: 添加环境变量支持以覆盖 max_tokens 设置
* feat(langfuse): LLM generation 记录工具定义
将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: 添加对 ACP 协议的支持 (#284)
* feat: 适配 zed acp 协议
* docs: 完善 acp 文档
* chore: 1.4.0
* conflict: 解决冲突
* feat: 添加测试覆盖率上报
* style: 改名加移动文件夹位置
* refactor: 移动测试用例及实现
* test: 修复测试用例完成
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cheng Zi Feng <1154238323@qq.com>
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
Co-authored-by: claude-code-best <272536312+claude-code-best@users.noreply.github.com>
286 lines
8.0 KiB
TypeScript
286 lines
8.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
|
|
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
|
|
// src/* path alias resolution. Provide AbortError directly so the dynamic
|
|
// import in createAdapter() never needs to resolve the alias at runtime.
|
|
const _abortMock = () => ({
|
|
AbortError: class AbortError extends Error {
|
|
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
|
},
|
|
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
|
})
|
|
mock.module('src/utils/errors.js', _abortMock)
|
|
mock.module('src/utils/errors', _abortMock)
|
|
|
|
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
|
|
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
|
|
|
describe('BraveSearchAdapter.search', () => {
|
|
const createAdapter = async () => {
|
|
const { BraveSearchAdapter } = await import('../adapters/braveAdapter')
|
|
return new BraveSearchAdapter()
|
|
}
|
|
|
|
const SAMPLE_RESPONSE = {
|
|
grounding: {
|
|
generic: [
|
|
{
|
|
title: 'Result One',
|
|
url: 'https://example.com/result1',
|
|
snippets: ['Snippet one'],
|
|
},
|
|
{
|
|
title: 'Result Two',
|
|
url: 'https://example.com/result2',
|
|
snippets: ['Snippet two'],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env.BRAVE_SEARCH_API_KEY = 'test-brave-key'
|
|
delete process.env.BRAVE_API_KEY
|
|
})
|
|
|
|
afterEach(() => {
|
|
mock.restore()
|
|
|
|
if (originalBraveSearchApiKey === undefined) {
|
|
delete process.env.BRAVE_SEARCH_API_KEY
|
|
} else {
|
|
process.env.BRAVE_SEARCH_API_KEY = originalBraveSearchApiKey
|
|
}
|
|
|
|
if (originalBraveApiKey === undefined) {
|
|
delete process.env.BRAVE_API_KEY
|
|
} else {
|
|
process.env.BRAVE_API_KEY = originalBraveApiKey
|
|
}
|
|
})
|
|
|
|
test('returns parsed results from Brave LLM context payload', async () => {
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
const results = await adapter.search('test query', {})
|
|
|
|
expect(results).toHaveLength(2)
|
|
expect(results[0]).toEqual({
|
|
title: 'Result One',
|
|
url: 'https://example.com/result1',
|
|
snippet: 'Snippet one',
|
|
})
|
|
expect(results[1].title).toBe('Result Two')
|
|
})
|
|
|
|
test('calls onProgress with query_update and search_results_received', async () => {
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const progressCalls: any[] = []
|
|
const onProgress = (p: any) => progressCalls.push(p)
|
|
|
|
const adapter = await createAdapter()
|
|
await adapter.search('test', { onProgress })
|
|
|
|
expect(progressCalls).toHaveLength(2)
|
|
expect(progressCalls[0]).toEqual({
|
|
type: 'query_update',
|
|
query: 'test',
|
|
})
|
|
expect(progressCalls[1]).toEqual({
|
|
type: 'search_results_received',
|
|
resultCount: 2,
|
|
query: 'test',
|
|
})
|
|
})
|
|
|
|
test('filters results by allowedDomains', async () => {
|
|
const mixedResponse = {
|
|
grounding: {
|
|
generic: [
|
|
{ title: 'Allowed', url: 'https://allowed.com/a' },
|
|
{ title: 'Blocked', url: 'https://blocked.com/b' },
|
|
],
|
|
},
|
|
}
|
|
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: mixedResponse })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
const results = await adapter.search('test', {
|
|
allowedDomains: ['allowed.com'],
|
|
})
|
|
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0].url).toBe('https://allowed.com/a')
|
|
})
|
|
|
|
test('filters results by blockedDomains', async () => {
|
|
const mixedResponse = {
|
|
grounding: {
|
|
generic: [
|
|
{ title: 'Good', url: 'https://good.com/a' },
|
|
{ title: 'Spam', url: 'https://spam.com/b' },
|
|
],
|
|
},
|
|
}
|
|
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: mixedResponse })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
const results = await adapter.search('test', {
|
|
blockedDomains: ['spam.com'],
|
|
})
|
|
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0].url).toBe('https://good.com/a')
|
|
})
|
|
|
|
test('filters subdomains with allowedDomains', async () => {
|
|
const response = {
|
|
grounding: {
|
|
generic: [
|
|
{ title: 'Subdomain', url: 'https://docs.example.com/page' },
|
|
{ title: 'Other', url: 'https://other.com/page' },
|
|
],
|
|
},
|
|
}
|
|
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: response })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
const results = await adapter.search('test', {
|
|
allowedDomains: ['example.com'],
|
|
})
|
|
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0].url).toBe('https://docs.example.com/page')
|
|
})
|
|
|
|
test('throws AbortError when signal is already aborted', async () => {
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock((_url: string, config: any) => {
|
|
if (config?.signal?.aborted) {
|
|
const err = new Error('canceled')
|
|
;(err as any).__CANCEL__ = true
|
|
return Promise.reject(err)
|
|
}
|
|
return Promise.resolve({ data: SAMPLE_RESPONSE })
|
|
}),
|
|
isCancel: (e: any) => e?.__CANCEL__ === true,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
const { AbortError } = await import('src/utils/errors')
|
|
await expect(
|
|
adapter.search('test', { signal: controller.signal }),
|
|
).rejects.toThrow(AbortError)
|
|
})
|
|
|
|
test('re-throws non-abort axios errors', async () => {
|
|
const networkError = new Error('Network error')
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.reject(networkError)),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
|
|
})
|
|
|
|
test('sends the documented HTTPS endpoint with query params and auth header', async () => {
|
|
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: axiosGet,
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
await adapter.search('hello world & special=chars', {})
|
|
|
|
expect(axiosGet.mock.calls).toHaveLength(1)
|
|
expect((axiosGet.mock.calls as any[][])[0][0]).toBe(
|
|
'https://api.search.brave.com/res/v1/llm/context',
|
|
)
|
|
expect((axiosGet.mock.calls as any[][])[0][1]).toMatchObject({
|
|
params: { q: 'hello world & special=chars' },
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Subscription-Token': 'test-brave-key',
|
|
},
|
|
})
|
|
})
|
|
|
|
test('accepts BRAVE_API_KEY as a fallback env var', async () => {
|
|
delete process.env.BRAVE_SEARCH_API_KEY
|
|
process.env.BRAVE_API_KEY = 'fallback-key'
|
|
|
|
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: axiosGet,
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
await adapter.search('test', {})
|
|
|
|
expect((axiosGet.mock.calls as any[][])[0][1].headers).toMatchObject({
|
|
'X-Subscription-Token': 'fallback-key',
|
|
})
|
|
})
|
|
|
|
test('throws when no Brave API key is configured', async () => {
|
|
delete process.env.BRAVE_SEARCH_API_KEY
|
|
delete process.env.BRAVE_API_KEY
|
|
|
|
mock.module('axios', () => ({
|
|
default: {
|
|
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
|
|
isCancel: () => false,
|
|
},
|
|
}))
|
|
|
|
const adapter = await createAdapter()
|
|
await expect(adapter.search('test', {})).rejects.toThrow(
|
|
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
|
|
)
|
|
})
|
|
})
|