mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 重构供应商层次 (#286)
* 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>
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -23,8 +23,13 @@ jobs:
|
|||||||
- name: Type check
|
- name: Type check
|
||||||
run: bunx tsc --noEmit
|
run: bunx tsc --noEmit
|
||||||
|
|
||||||
- name: Test
|
- name: Test with Coverage
|
||||||
run: bun test
|
run: bun test --coverage --coverage-reporter=lcov
|
||||||
|
|
||||||
|
- name: Upload coverage to Codecov
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build:vite
|
run: bun run build:vite
|
||||||
|
|||||||
11
bun.lock
11
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"@ant/computer-use-input": "workspace:*",
|
"@ant/computer-use-input": "workspace:*",
|
||||||
"@ant/computer-use-mcp": "workspace:*",
|
"@ant/computer-use-mcp": "workspace:*",
|
||||||
"@ant/computer-use-swift": "workspace:*",
|
"@ant/computer-use-swift": "workspace:*",
|
||||||
|
"@ant/model-provider": "workspace:*",
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
||||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||||
@@ -183,6 +184,14 @@
|
|||||||
"wrap-ansi": "^10.0.0",
|
"wrap-ansi": "^10.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/@ant/model-provider": {
|
||||||
|
"name": "@ant/model-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
|
"openai": "^6.33.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/agent-tools": {
|
"packages/agent-tools": {
|
||||||
"name": "@claude-code-best/agent-tools",
|
"name": "@claude-code-best/agent-tools",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -269,6 +278,8 @@
|
|||||||
|
|
||||||
"@ant/computer-use-swift": ["@ant/computer-use-swift@workspace:packages/@ant/computer-use-swift"],
|
"@ant/computer-use-swift": ["@ant/computer-use-swift@workspace:packages/@ant/computer-use-swift"],
|
||||||
|
|
||||||
|
"@ant/model-provider": ["@ant/model-provider@workspace:packages/@ant/model-provider"],
|
||||||
|
|
||||||
"@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.26.4", "https://registry.npmmirror.com/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.26.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-0Z2NY3T2wnzT9esRit6BiWpQXvL+F2b3z3Z9in3mXh7MDf122rVi2bcPowQHmo9ITXAPJmv/3H3t0V1z3Fugfw=="],
|
"@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.26.4", "https://registry.npmmirror.com/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.26.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-0Z2NY3T2wnzT9esRit6BiWpQXvL+F2b3z3Z9in3mXh7MDf122rVi2bcPowQHmo9ITXAPJmv/3H3t0V1z3Fugfw=="],
|
||||||
|
|
||||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.104.tgz", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],
|
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.104.tgz", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],
|
||||||
|
|||||||
17
docs/diagrams/agent-loop-simple.mmd
Normal file
17
docs/diagrams/agent-loop-simple.mmd
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
flowchart TB
|
||||||
|
START((输入)) --> CTX["Context 管理"]
|
||||||
|
CTX --> LLM["LLM 流式输出"]
|
||||||
|
LLM --> TC{tool_use?}
|
||||||
|
|
||||||
|
TC --> |是| EXEC["执行工具"]
|
||||||
|
EXEC --> CTX
|
||||||
|
|
||||||
|
TC --> |否| DONE((完成))
|
||||||
|
|
||||||
|
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||||
|
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||||
|
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||||
|
|
||||||
|
class CTX,LLM,EXEC proc
|
||||||
|
class TC decision
|
||||||
|
class START,DONE io
|
||||||
40
docs/diagrams/agent-loop.mmd
Normal file
40
docs/diagrams/agent-loop.mmd
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
flowchart TB
|
||||||
|
START((输入)) --> CTX["Context 管理"]
|
||||||
|
CTX --> PRE["Pre-sampling Hook"]
|
||||||
|
PRE --> LLM["LLM 流式输出"]
|
||||||
|
LLM --> TC{tool_use?}
|
||||||
|
|
||||||
|
TC --> |是| PERM{需权限?}
|
||||||
|
PERM --> |是| USER["👤 用户审批"]
|
||||||
|
USER --> |allow| TOOL_PRE
|
||||||
|
USER --> |deny| DENIED["拒绝"]
|
||||||
|
PERM --> |否| TOOL_PRE["Pre-tool Hook"]
|
||||||
|
TOOL_PRE --> EXEC["并发执行工具"]
|
||||||
|
EXEC --> TOOL_POST["Post-tool Hook"]
|
||||||
|
TOOL_POST --> CTX
|
||||||
|
DENIED --> CTX
|
||||||
|
|
||||||
|
TC --> |否| POST["Post-sampling Hook"]
|
||||||
|
POST --> STOP{"Stop Hook"}
|
||||||
|
STOP --> |不通过| CTX
|
||||||
|
STOP --> |通过| BUDGET{"Token Budget"}
|
||||||
|
BUDGET --> |继续| CTX
|
||||||
|
BUDGET --> |完成| DONE((完成))
|
||||||
|
|
||||||
|
subgraph SUB["子 Agent"]
|
||||||
|
FORK["AgentTool"] --> RECURSE["递归调用"]
|
||||||
|
end
|
||||||
|
|
||||||
|
EXEC -.-> FORK
|
||||||
|
|
||||||
|
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||||
|
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||||
|
classDef hook fill:#ffe,stroke:#cc6,color:#442
|
||||||
|
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||||
|
classDef sub fill:#efe,stroke:#6a6,color:#242
|
||||||
|
|
||||||
|
class CTX,LLM,EXEC proc
|
||||||
|
class TC,PERM,STOP,BUDGET decision
|
||||||
|
class PRE,TOOL_PRE,TOOL_POST,POST hook
|
||||||
|
class START,DONE,USER,DENIED io
|
||||||
|
class FORK,RECURSE sub
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/@ant/*"
|
"packages/@ant/*",
|
||||||
|
"packages/@anthropic-ai/*"
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||||
|
"@ant/model-provider": "workspace:*",
|
||||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||||
"@ant/computer-use-input": "workspace:*",
|
"@ant/computer-use-input": "workspace:*",
|
||||||
"@ant/computer-use-mcp": "workspace:*",
|
"@ant/computer-use-mcp": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/@ant/model-provider/package.json
Normal file
18
packages/@ant/model-provider/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@ant/model-provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./types": "./src/types/index.ts",
|
||||||
|
"./hooks": "./src/hooks/index.ts",
|
||||||
|
"./client": "./src/client/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
|
"openai": "^6.33.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/@ant/model-provider/src/client/index.ts
Normal file
27
packages/@ant/model-provider/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ClientFactories } from './types.js'
|
||||||
|
|
||||||
|
let registeredFactories: ClientFactories | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register client factories from the main project.
|
||||||
|
* Call this during application initialization.
|
||||||
|
*/
|
||||||
|
export function registerClientFactories(factories: ClientFactories): void {
|
||||||
|
registeredFactories = factories
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get registered client factories.
|
||||||
|
* Throws if not registered (fail-fast).
|
||||||
|
*/
|
||||||
|
export function getClientFactories(): ClientFactories {
|
||||||
|
if (!registeredFactories) {
|
||||||
|
throw new Error(
|
||||||
|
'Client factories not registered. ' +
|
||||||
|
'Call registerClientFactories() during app initialization.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return registeredFactories
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ClientFactories }
|
||||||
35
packages/@ant/model-provider/src/client/types.ts
Normal file
35
packages/@ant/model-provider/src/client/types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Client factory interfaces.
|
||||||
|
* Authentication is handled externally — main project provides factory implementations.
|
||||||
|
*/
|
||||||
|
export interface ClientFactories {
|
||||||
|
/** Get Anthropic client (1st party, Bedrock, Foundry, Vertex) */
|
||||||
|
getAnthropicClient: (params: {
|
||||||
|
model?: string
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => Promise<unknown>
|
||||||
|
|
||||||
|
/** Get OpenAI-compatible client */
|
||||||
|
getOpenAIClient: (params: {
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => unknown
|
||||||
|
|
||||||
|
/** Stream Gemini generate content */
|
||||||
|
streamGeminiGenerateContent: (params: {
|
||||||
|
model: string
|
||||||
|
signal?: AbortSignal
|
||||||
|
fetchOverride?: unknown
|
||||||
|
body: Record<string, unknown>
|
||||||
|
}) => AsyncIterable<unknown>
|
||||||
|
|
||||||
|
/** Get Grok client (OpenAI-compatible) */
|
||||||
|
getGrokClient: (params: {
|
||||||
|
maxRetries: number
|
||||||
|
fetchOverride?: unknown
|
||||||
|
source?: string
|
||||||
|
}) => unknown
|
||||||
|
}
|
||||||
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import type { APIError } from '@anthropic-ai/sdk'
|
||||||
|
|
||||||
|
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
|
||||||
|
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
|
||||||
|
const SSL_ERROR_CODES = new Set([
|
||||||
|
// Certificate verification errors
|
||||||
|
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||||
|
'UNABLE_TO_GET_ISSUER_CERT',
|
||||||
|
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||||
|
'CERT_SIGNATURE_FAILURE',
|
||||||
|
'CERT_NOT_YET_VALID',
|
||||||
|
'CERT_HAS_EXPIRED',
|
||||||
|
'CERT_REVOKED',
|
||||||
|
'CERT_REJECTED',
|
||||||
|
'CERT_UNTRUSTED',
|
||||||
|
// Self-signed certificate errors
|
||||||
|
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||||
|
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||||
|
// Chain errors
|
||||||
|
'CERT_CHAIN_TOO_LONG',
|
||||||
|
'PATH_LENGTH_EXCEEDED',
|
||||||
|
// Hostname/altname errors
|
||||||
|
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||||
|
'HOSTNAME_MISMATCH',
|
||||||
|
// TLS handshake errors
|
||||||
|
'ERR_TLS_HANDSHAKE_TIMEOUT',
|
||||||
|
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||||
|
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||||
|
])
|
||||||
|
|
||||||
|
export type ConnectionErrorDetails = {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
isSSLError: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts connection error details from the error cause chain.
|
||||||
|
* The Anthropic SDK wraps underlying errors in the `cause` property.
|
||||||
|
* This function walks the cause chain to find the root error code/message.
|
||||||
|
*/
|
||||||
|
export function extractConnectionErrorDetails(
|
||||||
|
error: unknown,
|
||||||
|
): ConnectionErrorDetails | null {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the cause chain to find the root error with a code
|
||||||
|
let current: unknown = error
|
||||||
|
const maxDepth = 5 // Prevent infinite loops
|
||||||
|
let depth = 0
|
||||||
|
|
||||||
|
while (current && depth < maxDepth) {
|
||||||
|
if (
|
||||||
|
current instanceof Error &&
|
||||||
|
'code' in current &&
|
||||||
|
typeof current.code === 'string'
|
||||||
|
) {
|
||||||
|
const code = current.code
|
||||||
|
const isSSLError = SSL_ERROR_CODES.has(code)
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
message: current.message,
|
||||||
|
isSSLError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to the next cause in the chain
|
||||||
|
if (
|
||||||
|
current instanceof Error &&
|
||||||
|
'cause' in current &&
|
||||||
|
current.cause !== current
|
||||||
|
) {
|
||||||
|
current = current.cause
|
||||||
|
depth++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
|
||||||
|
* the main API client (OAuth token exchange, preflight connectivity checks)
|
||||||
|
* where `formatAPIError` doesn't apply.
|
||||||
|
*/
|
||||||
|
export function getSSLErrorHint(error: unknown): string | null {
|
||||||
|
const details = extractConnectionErrorDetails(error)
|
||||||
|
if (!details?.isSSLError) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
|
||||||
|
* returning a user-friendly title or empty string if HTML is detected.
|
||||||
|
* Returns the original message unchanged if no HTML is found.
|
||||||
|
*/
|
||||||
|
function sanitizeMessageHTML(message: string): string {
|
||||||
|
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
|
||||||
|
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
|
||||||
|
if (titleMatch && titleMatch[1]) {
|
||||||
|
return titleMatch[1].trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
|
||||||
|
* and returns a user-friendly message instead
|
||||||
|
*/
|
||||||
|
export function sanitizeAPIError(apiError: APIError): string {
|
||||||
|
const message = apiError.message
|
||||||
|
if (!message) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return sanitizeMessageHTML(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes of deserialized API errors from session JSONL.
|
||||||
|
*/
|
||||||
|
type NestedAPIError = {
|
||||||
|
error?: {
|
||||||
|
message?: string
|
||||||
|
error?: { message?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNestedError(value: unknown): value is NestedAPIError {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'error' in value &&
|
||||||
|
typeof value.error === 'object' &&
|
||||||
|
value.error !== null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a human-readable message from a deserialized API error that lacks
|
||||||
|
* a top-level `.message`.
|
||||||
|
*/
|
||||||
|
function extractNestedErrorMessage(error: APIError): string | null {
|
||||||
|
if (!hasNestedError(error)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrowed: NestedAPIError = error
|
||||||
|
const nested = narrowed.error
|
||||||
|
|
||||||
|
// Standard Anthropic API shape: { error: { error: { message } } }
|
||||||
|
const deepMsg = nested?.error?.message
|
||||||
|
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
|
||||||
|
const sanitized = sanitizeMessageHTML(deepMsg)
|
||||||
|
if (sanitized.length > 0) {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock shape: { error: { message } }
|
||||||
|
const msg = nested?.message
|
||||||
|
if (typeof msg === 'string' && msg.length > 0) {
|
||||||
|
const sanitized = sanitizeMessageHTML(msg)
|
||||||
|
if (sanitized.length > 0) {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAPIError(error: APIError): string {
|
||||||
|
// Extract connection error details from the cause chain
|
||||||
|
const connectionDetails = extractConnectionErrorDetails(error)
|
||||||
|
|
||||||
|
if (connectionDetails) {
|
||||||
|
const { code, isSSLError } = connectionDetails
|
||||||
|
|
||||||
|
// Handle timeout errors
|
||||||
|
if (code === 'ETIMEDOUT') {
|
||||||
|
return 'Request timed out. Check your internet connection and proxy settings'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SSL/TLS errors with specific messages
|
||||||
|
if (isSSLError) {
|
||||||
|
switch (code) {
|
||||||
|
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
||||||
|
case 'UNABLE_TO_GET_ISSUER_CERT':
|
||||||
|
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
||||||
|
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
|
||||||
|
case 'CERT_HAS_EXPIRED':
|
||||||
|
return 'Unable to connect to API: SSL certificate has expired'
|
||||||
|
case 'CERT_REVOKED':
|
||||||
|
return 'Unable to connect to API: SSL certificate has been revoked'
|
||||||
|
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||||
|
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
||||||
|
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
|
||||||
|
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
||||||
|
case 'HOSTNAME_MISMATCH':
|
||||||
|
return 'Unable to connect to API: SSL certificate hostname mismatch'
|
||||||
|
case 'CERT_NOT_YET_VALID':
|
||||||
|
return 'Unable to connect to API: SSL certificate is not yet valid'
|
||||||
|
default:
|
||||||
|
return `Unable to connect to API: SSL error (${code})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message === 'Connection error.') {
|
||||||
|
// If we have a code but it's not SSL, include it for debugging
|
||||||
|
if (connectionDetails?.code) {
|
||||||
|
return `Unable to connect to API (${connectionDetails.code})`
|
||||||
|
}
|
||||||
|
return 'Unable to connect to API. Check your internet connection'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
|
||||||
|
// be a plain object without a `.message` property.
|
||||||
|
if (!error.message) {
|
||||||
|
return (
|
||||||
|
extractNestedErrorMessage(error) ??
|
||||||
|
`API error (status ${error.status ?? 'unknown'})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedMessage = sanitizeAPIError(error)
|
||||||
|
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
|
||||||
|
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
|
||||||
|
? sanitizedMessage
|
||||||
|
: error.message
|
||||||
|
}
|
||||||
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ModelProviderHooks } from './types.js'
|
||||||
|
|
||||||
|
let registeredHooks: ModelProviderHooks | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register hooks from the main project.
|
||||||
|
* Call this during application initialization.
|
||||||
|
*/
|
||||||
|
export function registerHooks(hooks: ModelProviderHooks): void {
|
||||||
|
registeredHooks = hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get registered hooks.
|
||||||
|
* Throws if hooks not registered (fail-fast).
|
||||||
|
*/
|
||||||
|
export function getHooks(): ModelProviderHooks {
|
||||||
|
if (!registeredHooks) {
|
||||||
|
throw new Error(
|
||||||
|
'ModelProvider hooks not registered. ' +
|
||||||
|
'Call registerHooks() during app initialization.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return registeredHooks
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ModelProviderHooks }
|
||||||
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for dependency injection.
|
||||||
|
* Main project provides implementations; model-provider calls them.
|
||||||
|
*
|
||||||
|
* This decouples the model-provider from main project specifics like
|
||||||
|
* analytics, cost tracking, feature flags, etc.
|
||||||
|
*/
|
||||||
|
export interface ModelProviderHooks {
|
||||||
|
/** Log an analytics event (replaces direct logEvent calls) */
|
||||||
|
logEvent: (eventName: string, metadata?: Record<string, unknown>) => void
|
||||||
|
|
||||||
|
/** Report API cost after each response */
|
||||||
|
reportCost: (params: {
|
||||||
|
costUSD: number
|
||||||
|
usage: Record<string, unknown>
|
||||||
|
model: string
|
||||||
|
}) => void
|
||||||
|
|
||||||
|
/** Get tool permission context */
|
||||||
|
getToolPermissionContext?: () => Promise<Record<string, unknown>>
|
||||||
|
|
||||||
|
/** Debug logging */
|
||||||
|
logForDebugging: (msg: string, opts?: { level?: string }) => void
|
||||||
|
|
||||||
|
/** Error logging */
|
||||||
|
logError: (error: Error) => void
|
||||||
|
|
||||||
|
/** Get feature flag value */
|
||||||
|
getFeatureFlag?: (flagName: string) => unknown
|
||||||
|
|
||||||
|
/** Get session ID */
|
||||||
|
getSessionId: () => string
|
||||||
|
|
||||||
|
/** Add a notification */
|
||||||
|
addNotification?: (notification: Record<string, unknown>) => void
|
||||||
|
|
||||||
|
/** Get API provider name */
|
||||||
|
getAPIProvider: () => string
|
||||||
|
|
||||||
|
/** Get user ID */
|
||||||
|
getOrCreateUserID: () => string
|
||||||
|
|
||||||
|
/** Check if non-interactive session */
|
||||||
|
isNonInteractiveSession: () => boolean
|
||||||
|
|
||||||
|
/** Get OAuth account info */
|
||||||
|
getOauthAccountInfo?: () => Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
63
packages/@ant/model-provider/src/index.ts
Normal file
63
packages/@ant/model-provider/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// @ant/model-provider
|
||||||
|
// Model provider abstraction layer for Claude Code
|
||||||
|
//
|
||||||
|
// This package owns the model calling logic and provides:
|
||||||
|
// - Core query functions (queryModelWithStreaming, etc.)
|
||||||
|
// - Provider implementations (Anthropic, OpenAI, Gemini, Grok)
|
||||||
|
// - Type definitions (Message, Tool, Usage, etc.)
|
||||||
|
// - Dependency injection hooks (analytics, cost tracking, etc.)
|
||||||
|
//
|
||||||
|
// Initialization:
|
||||||
|
// registerClientFactories({ ... }) // inject auth clients
|
||||||
|
// registerHooks({ ... }) // inject analytics/cost/logging
|
||||||
|
|
||||||
|
// Hooks (dependency injection)
|
||||||
|
export { registerHooks, getHooks } from './hooks/index.js'
|
||||||
|
export type { ModelProviderHooks } from './hooks/types.js'
|
||||||
|
|
||||||
|
// Client factories
|
||||||
|
export { registerClientFactories, getClientFactories } from './client/index.js'
|
||||||
|
export type { ClientFactories } from './client/types.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types/index.js'
|
||||||
|
|
||||||
|
// Provider model mappings
|
||||||
|
export { resolveOpenAIModel } from './providers/openai/modelMapping.js'
|
||||||
|
export { resolveGrokModel } from './providers/grok/modelMapping.js'
|
||||||
|
export { resolveGeminiModel } from './providers/gemini/modelMapping.js'
|
||||||
|
|
||||||
|
// Gemini provider utilities
|
||||||
|
export { anthropicMessagesToGemini } from './providers/gemini/convertMessages.js'
|
||||||
|
export { anthropicToolsToGemini, anthropicToolChoiceToGemini } from './providers/gemini/convertTools.js'
|
||||||
|
export { adaptGeminiStreamToAnthropic } from './providers/gemini/streamAdapter.js'
|
||||||
|
export {
|
||||||
|
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||||
|
type GeminiContent,
|
||||||
|
type GeminiGenerateContentRequest,
|
||||||
|
type GeminiPart,
|
||||||
|
type GeminiStreamChunk,
|
||||||
|
type GeminiTool,
|
||||||
|
type GeminiFunctionCallingConfig,
|
||||||
|
type GeminiFunctionDeclaration,
|
||||||
|
type GeminiFunctionCall,
|
||||||
|
type GeminiFunctionResponse,
|
||||||
|
type GeminiInlineData,
|
||||||
|
type GeminiUsageMetadata,
|
||||||
|
type GeminiCandidate,
|
||||||
|
} from './providers/gemini/types.js'
|
||||||
|
|
||||||
|
// Error utilities
|
||||||
|
export {
|
||||||
|
formatAPIError,
|
||||||
|
extractConnectionErrorDetails,
|
||||||
|
sanitizeAPIError,
|
||||||
|
getSSLErrorHint,
|
||||||
|
type ConnectionErrorDetails,
|
||||||
|
} from './errorUtils.js'
|
||||||
|
|
||||||
|
// Shared OpenAI conversion utilities
|
||||||
|
export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
|
||||||
|
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
||||||
|
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
||||||
|
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
|||||||
import type {
|
import type {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
} from '../../../../types/message.js'
|
} from '../../../types/message.js'
|
||||||
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||||
|
|
||||||
function makeUserMsg(content: string | any[]): UserMessage {
|
function makeUserMsg(content: string | any[]): UserMessage {
|
||||||
@@ -2,9 +2,8 @@ import type {
|
|||||||
BetaToolResultBlockParam,
|
BetaToolResultBlockParam,
|
||||||
BetaToolUseBlock,
|
BetaToolUseBlock,
|
||||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||||
import { safeParseJSON } from '../../../utils/json.js'
|
import type { SystemPrompt } from '../../types/systemPrompt.js'
|
||||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
|
||||||
import {
|
import {
|
||||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||||
type GeminiContent,
|
type GeminiContent,
|
||||||
@@ -12,6 +11,16 @@ import {
|
|||||||
type GeminiPart,
|
type GeminiPart,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
// Simple JSON parse utility (replaces safeParseJSON from main project)
|
||||||
|
function safeParseJSON(json: string | null | undefined): unknown {
|
||||||
|
if (!json) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(json)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function anthropicMessagesToGemini(
|
export function anthropicMessagesToGemini(
|
||||||
messages: (UserMessage | AssistantMessage)[],
|
messages: (UserMessage | AssistantMessage)[],
|
||||||
systemPrompt: SystemPrompt,
|
systemPrompt: SystemPrompt,
|
||||||
@@ -113,7 +122,7 @@ function convertUserContentBlockToGeminiParts(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 Anthropic image 块转换为 Gemini inlineData
|
// Convert Anthropic image blocks to Gemini inlineData
|
||||||
if (block.type === 'image') {
|
if (block.type === 'image') {
|
||||||
const source = block.source as Record<string, unknown> | undefined
|
const source = block.source as Record<string, unknown> | undefined
|
||||||
if (source?.type === 'base64' && typeof source.data === 'string') {
|
if (source?.type === 'base64' && typeof source.data === 'string') {
|
||||||
@@ -127,7 +136,7 @@ function convertUserContentBlockToGeminiParts(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
// url 类型的图片,Gemini 不直接支持,转为文本描述
|
// URL images not directly supported by Gemini, convert to text description
|
||||||
if (source?.type === 'url' && typeof source.url === 'string') {
|
if (source?.type === 'url' && typeof source.url === 'string') {
|
||||||
return createTextGeminiParts(`[image: ${source.url}]`)
|
return createTextGeminiParts(`[image: ${source.url}]`)
|
||||||
}
|
}
|
||||||
@@ -17,14 +17,12 @@ export function resolveGeminiModel(anthropicModel: string): string {
|
|||||||
return cleanModel
|
return cleanModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, try Gemini-specific DEFAULT variables (separated from Anthropic)
|
|
||||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const geminiModel = process.env[geminiEnvVar]
|
const geminiModel = process.env[geminiEnvVar]
|
||||||
if (geminiModel) {
|
if (geminiModel) {
|
||||||
return geminiModel
|
return geminiModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Anthropic DEFAULT variables for backward compatibility
|
|
||||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const resolvedModel = process.env[sharedEnvVar]
|
const resolvedModel = process.env[sharedEnvVar]
|
||||||
if (resolvedModel) {
|
if (resolvedModel) {
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
* Default mapping from Anthropic model names to Grok model names.
|
* Default mapping from Anthropic model names to Grok model names.
|
||||||
*
|
*
|
||||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string):
|
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||||
* GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}'
|
|
||||||
*/
|
*/
|
||||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||||
@@ -19,9 +18,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|||||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Family-level mapping defaults (used by GROK_MODEL_MAP).
|
|
||||||
*/
|
|
||||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||||
opus: 'grok-4.20-reasoning',
|
opus: 'grok-4.20-reasoning',
|
||||||
sonnet: 'grok-3-mini-fast',
|
sonnet: 'grok-3-mini-fast',
|
||||||
@@ -35,10 +31,6 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse user-provided model map from GROK_MODEL_MAP env var.
|
|
||||||
* Accepts JSON like: {"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}
|
|
||||||
*/
|
|
||||||
function getUserModelMap(): Record<string, string> | null {
|
function getUserModelMap(): Record<string, string> | null {
|
||||||
const raw = process.env.GROK_MODEL_MAP
|
const raw = process.env.GROK_MODEL_MAP
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@@ -55,18 +47,8 @@ function getUserModelMap(): Record<string, string> | null {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the Grok model name for a given Anthropic model.
|
* Resolve the Grok model name for a given Anthropic model.
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. GROK_MODEL env var (override all)
|
|
||||||
* 2. GROK_MODEL_MAP env var — JSON family map (e.g. {"opus":"grok-4"})
|
|
||||||
* 3. GROK_DEFAULT_{FAMILY}_MODEL env var (e.g. GROK_DEFAULT_OPUS_MODEL)
|
|
||||||
* 4. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compat)
|
|
||||||
* 5. DEFAULT_MODEL_MAP lookup
|
|
||||||
* 6. Family-level default
|
|
||||||
* 7. Pass through original model name
|
|
||||||
*/
|
*/
|
||||||
export function resolveGrokModel(anthropicModel: string): string {
|
export function resolveGrokModel(anthropicModel: string): string {
|
||||||
// 1. Global override
|
|
||||||
if (process.env.GROK_MODEL) {
|
if (process.env.GROK_MODEL) {
|
||||||
return process.env.GROK_MODEL
|
return process.env.GROK_MODEL
|
||||||
}
|
}
|
||||||
@@ -74,34 +56,28 @@ export function resolveGrokModel(anthropicModel: string): string {
|
|||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
const family = getModelFamily(cleanModel)
|
const family = getModelFamily(cleanModel)
|
||||||
|
|
||||||
// 2. User-provided model map
|
|
||||||
const userMap = getUserModelMap()
|
const userMap = getUserModelMap()
|
||||||
if (userMap && family && userMap[family]) {
|
if (userMap && family && userMap[family]) {
|
||||||
return userMap[family]
|
return userMap[family]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (family) {
|
if (family) {
|
||||||
// 3. Grok-specific family override
|
|
||||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const grokOverride = process.env[grokEnvVar]
|
const grokOverride = process.env[grokEnvVar]
|
||||||
if (grokOverride) return grokOverride
|
if (grokOverride) return grokOverride
|
||||||
|
|
||||||
// 4. Anthropic env var (backward compat)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
if (anthropicOverride) return anthropicOverride
|
if (anthropicOverride) return anthropicOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Exact model name lookup
|
|
||||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||||
return DEFAULT_MODEL_MAP[cleanModel]
|
return DEFAULT_MODEL_MAP[cleanModel]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Family-level default
|
|
||||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||||
return DEFAULT_FAMILY_MAP[family]
|
return DEFAULT_FAMILY_MAP[family]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Pass through
|
|
||||||
return cleanModel
|
return cleanModel
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
|||||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
|
|
||||||
*/
|
|
||||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
if (/haiku/i.test(model)) return 'haiku'
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
if (/opus/i.test(model)) return 'opus'
|
if (/opus/i.test(model)) return 'opus'
|
||||||
@@ -37,23 +34,18 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
|||||||
* 5. Pass through original model name
|
* 5. Pass through original model name
|
||||||
*/
|
*/
|
||||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||||
// Highest priority: explicit override
|
|
||||||
if (process.env.OPENAI_MODEL) {
|
if (process.env.OPENAI_MODEL) {
|
||||||
return process.env.OPENAI_MODEL
|
return process.env.OPENAI_MODEL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip [1m] suffix if present (Claude-specific modifier)
|
|
||||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||||
|
|
||||||
// Check family-specific overrides
|
|
||||||
const family = getModelFamily(cleanModel)
|
const family = getModelFamily(cleanModel)
|
||||||
if (family) {
|
if (family) {
|
||||||
// OpenAI-specific family override (preferred for openai provider)
|
|
||||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const openaiOverride = process.env[openaiEnvVar]
|
const openaiOverride = process.env[openaiEnvVar]
|
||||||
if (openaiOverride) return openaiOverride
|
if (openaiOverride) return openaiOverride
|
||||||
|
|
||||||
// Anthropic env var (backward compatibility)
|
|
||||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
const anthropicOverride = process.env[anthropicEnvVar]
|
const anthropicOverride = process.env[anthropicEnvVar]
|
||||||
if (anthropicOverride) return anthropicOverride
|
if (anthropicOverride) return anthropicOverride
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
|
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.js'
|
||||||
import type { UserMessage, AssistantMessage } from '../../../../types/message.js'
|
import type { UserMessage, AssistantMessage } from '../../types/message.js'
|
||||||
|
|
||||||
// Helpers to create internal-format messages
|
// Helpers to create internal-format messages
|
||||||
function makeUserMsg(content: string | any[]): UserMessage {
|
function makeUserMsg(content: string | any[]): UserMessage {
|
||||||
@@ -396,10 +396,6 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
{ enableThinking: true },
|
{ enableThinking: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// All 3 assistant messages are in the current turn (after last user msg is the last tool_result,
|
|
||||||
// but the "last user message" boundary logic finds the last user-typed message).
|
|
||||||
// Actually, tool_result messages are also UserMessage type, so the last user message
|
|
||||||
// is the one with tool_result for toolu_002. All assistant messages after that should have reasoning.
|
|
||||||
const assistants = result.filter(m => m.role === 'assistant')
|
const assistants = result.filter(m => m.role === 'assistant')
|
||||||
expect(assistants.length).toBe(3)
|
expect(assistants.length).toBe(3)
|
||||||
// All iterations within the same turn preserve reasoning
|
// All iterations within the same turn preserve reasoning
|
||||||
@@ -435,6 +431,54 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
|||||||
expect(assistant.reasoning_content).toBeUndefined()
|
expect(assistant.reasoning_content).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
|
||||||
|
|
||||||
|
test('tool messages come BEFORE user text when mixed in same turn', () => {
|
||||||
|
// OpenAI requires: assistant(tool_calls) → tool → user
|
||||||
|
// Bug: previously user text was emitted before tool messages
|
||||||
|
const result = anthropicMessagesToOpenAI(
|
||||||
|
[
|
||||||
|
makeUserMsg('run ls'),
|
||||||
|
makeAssistantMsg([
|
||||||
|
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
|
||||||
|
]),
|
||||||
|
makeUserMsg([
|
||||||
|
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
|
||||||
|
{ type: 'text' as const, text: 'looks good' },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
// Find the tool message and the user text message
|
||||||
|
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||||
|
const userTextIdx = result.findIndex(
|
||||||
|
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
|
||||||
|
)
|
||||||
|
expect(toolIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(userTextIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
// Tool MUST come before user text
|
||||||
|
expect(toolIdx).toBeLessThan(userTextIdx)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
|
||||||
|
const result = anthropicMessagesToOpenAI(
|
||||||
|
[
|
||||||
|
makeUserMsg('do something'),
|
||||||
|
makeAssistantMsg([
|
||||||
|
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
|
||||||
|
]),
|
||||||
|
makeUserMsg([
|
||||||
|
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
|
||||||
|
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||||
|
expect(assistantIdx).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(toolIdx).toBe(assistantIdx + 1)
|
||||||
|
})
|
||||||
|
|
||||||
test('sets content to null when only thinking and tool_calls present', () => {
|
test('sets content to null when only thinking and tool_calls present', () => {
|
||||||
const result = anthropicMessagesToOpenAI(
|
const result = anthropicMessagesToOpenAI(
|
||||||
[makeUserMsg('question'), makeAssistantMsg([
|
[makeUserMsg('question'), makeAssistantMsg([
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js'
|
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.js'
|
||||||
|
|
||||||
describe('anthropicToolsToOpenAI', () => {
|
describe('anthropicToolsToOpenAI', () => {
|
||||||
test('converts basic tool', () => {
|
test('converts basic tool', () => {
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import { join, dirname } from 'path'
|
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
||||||
import { tmpdir } from 'os'
|
|
||||||
|
|
||||||
// Guard against mock pollution from queryModelOpenAI.test.ts which replaces
|
|
||||||
// ../streamAdapter.js process-wide via mock.module (bun has no un-mock API).
|
|
||||||
// We copy the source to a unique temp path so the import bypasses bun's
|
|
||||||
// module mock cache completely.
|
|
||||||
const _testDir = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const _realSource = readFileSync(join(_testDir, '..', 'streamAdapter.ts'), 'utf-8')
|
|
||||||
const _tempDir = join(tmpdir(), `stream-adapter-test-${Date.now()}`)
|
|
||||||
mkdirSync(_tempDir, { recursive: true })
|
|
||||||
const _tempFile = join(_tempDir, 'streamAdapter.ts')
|
|
||||||
writeFileSync(_tempFile, _realSource, 'utf-8')
|
|
||||||
const { adaptOpenAIStreamToAnthropic } = await import(_tempFile)
|
|
||||||
|
|
||||||
/** Helper to create a mock async iterable from chunk array */
|
/** Helper to create a mock async iterable from chunk array */
|
||||||
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
||||||
@@ -46,11 +31,6 @@ function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatComp
|
|||||||
|
|
||||||
/** Collect all emitted Anthropic events from the stream adapter for assertion */
|
/** Collect all emitted Anthropic events from the stream adapter for assertion */
|
||||||
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
||||||
const realModuleUrl = new URL(
|
|
||||||
`../streamAdapter.js?real=${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
||||||
import.meta.url,
|
|
||||||
).href
|
|
||||||
const { adaptOpenAIStreamToAnthropic } = await import(realModuleUrl)
|
|
||||||
const events: any[] = []
|
const events: any[] = []
|
||||||
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
|
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
|
||||||
events.push(event)
|
events.push(event)
|
||||||
@@ -10,8 +10,8 @@ import type {
|
|||||||
ChatCompletionToolMessageParam,
|
ChatCompletionToolMessageParam,
|
||||||
ChatCompletionUserMessageParam,
|
ChatCompletionUserMessageParam,
|
||||||
} from 'openai/resources/chat/completions/completions.mjs'
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
import type { AssistantMessage, UserMessage } from '../types/message.js'
|
||||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
import type { SystemPrompt } from '../types/systemPrompt.js'
|
||||||
|
|
||||||
export interface ConvertMessagesOptions {
|
export interface ConvertMessagesOptions {
|
||||||
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
||||||
@@ -152,7 +152,6 @@ function convertInternalUserMessage(
|
|||||||
// OpenAI API requires that a tool message immediately follows the assistant
|
// OpenAI API requires that a tool message immediately follows the assistant
|
||||||
// message with tool_calls. If we emit a user message first, the API will
|
// message with tool_calls. If we emit a user message first, the API will
|
||||||
// reject the request with "insufficient tool messages following tool_calls".
|
// reject the request with "insufficient tool messages following tool_calls".
|
||||||
// See: https://github.com/anthropics/claude-code/issues/xxx
|
|
||||||
for (const tr of toolResults) {
|
for (const tr of toolResults) {
|
||||||
result.push(convertToolResult(tr))
|
result.push(convertToolResult(tr))
|
||||||
}
|
}
|
||||||
@@ -51,10 +51,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
let textBlockOpen = false
|
let textBlockOpen = false
|
||||||
|
|
||||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no standard OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
|
||||||
let inputTokens = 0
|
let inputTokens = 0
|
||||||
let outputTokens = 0
|
let outputTokens = 0
|
||||||
let cachedReadTokens = 0
|
let cachedReadTokens = 0
|
||||||
@@ -62,10 +58,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
// Track all open content block indices (for cleanup)
|
// Track all open content block indices (for cleanup)
|
||||||
const openBlockIndices = new Set<number>()
|
const openBlockIndices = new Set<number>()
|
||||||
|
|
||||||
// Deferred finish state: populated when finish_reason is encountered so that
|
// Deferred finish state
|
||||||
// message_delta / message_stop are emitted AFTER the stream loop ends.
|
|
||||||
// This ensures usage chunks that arrive after the finish_reason chunk are
|
|
||||||
// captured before we emit the final token counts.
|
|
||||||
let pendingFinishReason: string | null = null
|
let pendingFinishReason: string | null = null
|
||||||
let pendingHasToolCalls = false
|
let pendingHasToolCalls = false
|
||||||
|
|
||||||
@@ -74,16 +67,9 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
|
|
||||||
// Extract usage from any chunk that carries it.
|
// Extract usage from any chunk that carries it.
|
||||||
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
|
|
||||||
// final chunk that arrives AFTER the finish_reason chunk. Reading it here
|
|
||||||
// (before emitting message_delta) ensures the token counts are available
|
|
||||||
// when we later emit message_delta.
|
|
||||||
if (chunk.usage) {
|
if (chunk.usage) {
|
||||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||||
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
|
|
||||||
// → Anthropic cache_read_input_tokens
|
|
||||||
// Note: OpenAI has no equivalent for cache_creation_input_tokens.
|
|
||||||
const details = (chunk.usage as any).prompt_tokens_details
|
const details = (chunk.usage as any).prompt_tokens_details
|
||||||
if (details?.cached_tokens != null) {
|
if (details?.cached_tokens != null) {
|
||||||
cachedReadTokens = details.cached_tokens
|
cachedReadTokens = details.cached_tokens
|
||||||
@@ -118,7 +104,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
if (!delta) continue
|
if (!delta) continue
|
||||||
|
|
||||||
// Handle reasoning_content → Anthropic thinking block
|
// Handle reasoning_content → Anthropic thinking block
|
||||||
// DeepSeek and compatible providers send delta.reasoning_content
|
|
||||||
const reasoningContent = (delta as any).reasoning_content
|
const reasoningContent = (delta as any).reasoning_content
|
||||||
if (reasoningContent != null && reasoningContent !== '') {
|
if (reasoningContent != null && reasoningContent !== '') {
|
||||||
if (!thinkingBlockOpen) {
|
if (!thinkingBlockOpen) {
|
||||||
@@ -150,7 +135,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
// Handle text content
|
// Handle text content
|
||||||
if (delta.content != null && delta.content !== '') {
|
if (delta.content != null && delta.content !== '') {
|
||||||
if (!textBlockOpen) {
|
if (!textBlockOpen) {
|
||||||
// Close thinking block if still open (reasoning done, now generating answer)
|
// Close thinking block if still open
|
||||||
if (thinkingBlockOpen) {
|
if (thinkingBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -251,12 +236,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle finish: close all open content blocks and record the finish_reason.
|
// Handle finish
|
||||||
// message_delta + message_stop are emitted AFTER the stream loop so that any
|
|
||||||
// trailing usage chunk (sent after the finish chunk by some endpoints)
|
|
||||||
// is captured first — ensuring token counts are non-zero.
|
|
||||||
if (choice?.finish_reason) {
|
if (choice?.finish_reason) {
|
||||||
// Close thinking block if still open
|
|
||||||
if (thinkingBlockOpen) {
|
if (thinkingBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -266,7 +247,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
thinkingBlockOpen = false
|
thinkingBlockOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close text block if still open
|
|
||||||
if (textBlockOpen) {
|
if (textBlockOpen) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -276,7 +256,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
textBlockOpen = false
|
textBlockOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close all tool blocks that haven't been closed yet
|
|
||||||
for (const [, block] of toolBlocks) {
|
for (const [, block] of toolBlocks) {
|
||||||
if (openBlockIndices.has(block.contentIndex)) {
|
if (openBlockIndices.has(block.contentIndex)) {
|
||||||
yield {
|
yield {
|
||||||
@@ -287,14 +266,12 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defer message_delta / message_stop until after the loop so that any
|
|
||||||
// trailing usage chunk is processed before we emit the final token counts.
|
|
||||||
pendingFinishReason = choice.finish_reason
|
pendingFinishReason = choice.finish_reason
|
||||||
pendingHasToolCalls = toolBlocks.size > 0
|
pendingHasToolCalls = toolBlocks.size > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety: close any remaining open blocks if stream ended without finish_reason
|
// Safety: close any remaining open blocks
|
||||||
for (const idx of openBlockIndices) {
|
for (const idx of openBlockIndices) {
|
||||||
yield {
|
yield {
|
||||||
type: 'content_block_stop',
|
type: 'content_block_stop',
|
||||||
@@ -302,15 +279,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
} as BetaRawMessageStreamEvent
|
} as BetaRawMessageStreamEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit message_delta + message_stop now that the stream is fully consumed.
|
// Emit message_delta + message_stop
|
||||||
// Usage values (inputTokens / outputTokens) reflect all chunks including any
|
|
||||||
// trailing usage-only chunk sent after the finish_reason chunk.
|
|
||||||
if (pendingFinishReason !== null) {
|
if (pendingFinishReason !== null) {
|
||||||
// Map finish_reason to Anthropic stop_reason.
|
|
||||||
// CRITICAL: When finish_reason is 'length' (token budget exhausted), always
|
|
||||||
// report 'max_tokens' regardless of whether partial tool calls were received.
|
|
||||||
// Otherwise the query loop would try to execute tool calls with incomplete
|
|
||||||
// JSON arguments instead of triggering the max_tokens retry/recovery path.
|
|
||||||
const stopReason =
|
const stopReason =
|
||||||
pendingFinishReason === 'length'
|
pendingFinishReason === 'length'
|
||||||
? 'max_tokens'
|
? 'max_tokens'
|
||||||
@@ -324,19 +294,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
stop_reason: stopReason,
|
stop_reason: stopReason,
|
||||||
stop_sequence: null,
|
stop_sequence: null,
|
||||||
},
|
},
|
||||||
// Carry all four Anthropic usage fields so queryModelOpenAI's message_delta
|
|
||||||
// handler (which spreads this into the accumulated usage object) can override
|
|
||||||
// every field that message_start emitted as 0. For endpoints that send usage
|
|
||||||
// in a trailing chunk (e.g. DeepSeek), message_start is emitted on the first
|
|
||||||
// content chunk before the trailing usage chunk arrives, so all four fields
|
|
||||||
// start at 0. By the time we reach here (post-loop) the trailing chunk has
|
|
||||||
// been processed and all values reflect the real counts.
|
|
||||||
//
|
|
||||||
// OpenAI → Anthropic field mapping:
|
|
||||||
// prompt_tokens → input_tokens
|
|
||||||
// completion_tokens → output_tokens
|
|
||||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
|
||||||
// (no OpenAI equivalent) → cache_creation_input_tokens (stays 0)
|
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: inputTokens,
|
input_tokens: inputTokens,
|
||||||
output_tokens: outputTokens,
|
output_tokens: outputTokens,
|
||||||
@@ -353,11 +310,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||||
*
|
|
||||||
* stop → end_turn
|
|
||||||
* tool_calls → tool_use
|
|
||||||
* length → max_tokens
|
|
||||||
* content_filter → end_turn
|
|
||||||
*/
|
*/
|
||||||
function mapFinishReason(reason: string): string {
|
function mapFinishReason(reason: string): string {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Error type constants for the model provider package.
|
||||||
|
// Error string constants extracted from src/services/api/errors.ts.
|
||||||
|
// The full error handling functions remain in the main project (Phase 4).
|
||||||
|
|
||||||
|
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
||||||
|
|
||||||
|
export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
|
||||||
|
|
||||||
|
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
|
||||||
|
export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
|
||||||
|
export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
|
||||||
|
'Invalid API key · Fix external API key'
|
||||||
|
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
|
||||||
|
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
|
||||||
|
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
|
||||||
|
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
|
||||||
|
export const TOKEN_REVOKED_ERROR_MESSAGE =
|
||||||
|
'OAuth token revoked · Please run /login'
|
||||||
|
export const CCR_AUTH_ERROR_MESSAGE =
|
||||||
|
'Authentication error · This may be a temporary network issue, please try again'
|
||||||
|
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
|
||||||
|
export const CUSTOM_OFF_SWITCH_MESSAGE =
|
||||||
|
'Opus is experiencing high load, please use /model to switch to Sonnet'
|
||||||
|
export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
|
||||||
|
export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
|
||||||
|
'Your account does not have access to Claude Code. Please run /login.'
|
||||||
|
|
||||||
|
/** Error classification types returned by classifyAPIError */
|
||||||
|
export type APIErrorClassification =
|
||||||
|
| 'aborted'
|
||||||
|
| 'api_timeout'
|
||||||
|
| 'repeated_529'
|
||||||
|
| 'capacity_off_switch'
|
||||||
|
| 'rate_limit'
|
||||||
|
| 'server_overload'
|
||||||
|
| 'prompt_too_long'
|
||||||
|
| 'pdf_too_large'
|
||||||
|
| 'pdf_password_protected'
|
||||||
|
| 'image_too_large'
|
||||||
|
| 'tool_use_mismatch'
|
||||||
|
| 'unexpected_tool_result'
|
||||||
|
| 'duplicate_tool_use_id'
|
||||||
|
| 'invalid_model'
|
||||||
|
| 'credit_balance_low'
|
||||||
|
| 'invalid_api_key'
|
||||||
|
| 'token_revoked'
|
||||||
|
| 'oauth_org_not_allowed'
|
||||||
|
| 'auth_error'
|
||||||
|
| 'bedrock_model_access'
|
||||||
|
| 'server_error'
|
||||||
|
| 'client_error'
|
||||||
|
| 'ssl_cert_error'
|
||||||
|
| 'connection_error'
|
||||||
|
| 'unknown'
|
||||||
6
packages/@ant/model-provider/src/types/index.ts
Normal file
6
packages/@ant/model-provider/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Type definitions for @ant/model-provider
|
||||||
|
|
||||||
|
export * from './message.js'
|
||||||
|
export * from './usage.js'
|
||||||
|
export * from './errors.js'
|
||||||
|
export * from './systemPrompt.js'
|
||||||
129
packages/@ant/model-provider/src/types/message.ts
Normal file
129
packages/@ant/model-provider/src/types/message.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Core message types for the model provider package.
|
||||||
|
// Moved from src/types/message.ts to decouple the API layer from the main project.
|
||||||
|
|
||||||
|
import type { UUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
ContentBlockParam,
|
||||||
|
ContentBlock,
|
||||||
|
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||||
|
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base message type with discriminant `type` field and common properties.
|
||||||
|
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
|
||||||
|
* this with narrower `type` literals and additional fields.
|
||||||
|
*/
|
||||||
|
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
|
||||||
|
|
||||||
|
/** A single content element inside message.content arrays. */
|
||||||
|
export type ContentItem = ContentBlockParam | ContentBlock
|
||||||
|
|
||||||
|
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed content array — used in narrowed message subtypes so that
|
||||||
|
* `message.content[0]` resolves to `ContentItem` instead of
|
||||||
|
* `string | ContentBlockParam | ContentBlock`.
|
||||||
|
*/
|
||||||
|
export type TypedMessageContent = ContentItem[]
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
type: MessageType
|
||||||
|
uuid: UUID
|
||||||
|
isMeta?: boolean
|
||||||
|
isCompactSummary?: boolean
|
||||||
|
toolUseResult?: unknown
|
||||||
|
isVisibleInTranscriptOnly?: boolean
|
||||||
|
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
|
||||||
|
message?: {
|
||||||
|
role?: string
|
||||||
|
id?: string
|
||||||
|
content?: MessageContent
|
||||||
|
usage?: BetaUsage | Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistantMessage = Message & {
|
||||||
|
type: 'assistant'
|
||||||
|
message: NonNullable<Message['message']>
|
||||||
|
}
|
||||||
|
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
|
||||||
|
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
|
||||||
|
export type SystemLocalCommandMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMessage = Message & { type: 'system' }
|
||||||
|
export type UserMessage = Message & {
|
||||||
|
type: 'user'
|
||||||
|
message: NonNullable<Message['message']>
|
||||||
|
imagePasteIds?: number[]
|
||||||
|
}
|
||||||
|
export type NormalizedUserMessage = UserMessage
|
||||||
|
export type RequestStartEvent = { type: string; [key: string]: unknown }
|
||||||
|
export type StreamEvent = { type: string; [key: string]: unknown }
|
||||||
|
export type SystemCompactBoundaryMessage = Message & {
|
||||||
|
type: 'system'
|
||||||
|
compactMetadata: {
|
||||||
|
preservedSegment?: {
|
||||||
|
headUuid: UUID
|
||||||
|
tailUuid: UUID
|
||||||
|
anchorUuid: UUID
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type TombstoneMessage = Message
|
||||||
|
export type ToolUseSummaryMessage = Message
|
||||||
|
export type MessageOrigin = string
|
||||||
|
export type CompactMetadata = Record<string, unknown>
|
||||||
|
export type SystemAPIErrorMessage = Message & { type: 'system' }
|
||||||
|
export type SystemFileSnapshotMessage = Message & { type: 'system' }
|
||||||
|
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
|
||||||
|
export type NormalizedMessage = Message
|
||||||
|
export type PartialCompactDirection = string
|
||||||
|
|
||||||
|
export type StopHookInfo = {
|
||||||
|
command?: string
|
||||||
|
durationMs?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemAgentsKilledMessage = Message & { type: 'system' }
|
||||||
|
export type SystemApiMetricsMessage = Message & { type: 'system' }
|
||||||
|
export type SystemAwaySummaryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemBridgeStatusMessage = Message & { type: 'system' }
|
||||||
|
export type SystemInformationalMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMemorySavedMessage = Message & { type: 'system' }
|
||||||
|
export type SystemMessageLevel = string
|
||||||
|
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemPermissionRetryMessage = Message & { type: 'system' }
|
||||||
|
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
|
||||||
|
|
||||||
|
export type SystemStopHookSummaryMessage = Message & {
|
||||||
|
type: 'system'
|
||||||
|
subtype: string
|
||||||
|
hookLabel: string
|
||||||
|
hookCount: number
|
||||||
|
totalDurationMs?: number
|
||||||
|
hookInfos: StopHookInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemTurnDurationMessage = Message & { type: 'system' }
|
||||||
|
|
||||||
|
export type GroupedToolUseMessage = Message & {
|
||||||
|
type: 'grouped_tool_use'
|
||||||
|
toolName: string
|
||||||
|
messages: NormalizedAssistantMessage[]
|
||||||
|
results: NormalizedUserMessage[]
|
||||||
|
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollapsibleMessage is used by the main project's CollapsedReadSearchGroup
|
||||||
|
export type CollapsibleMessage =
|
||||||
|
| AssistantMessage
|
||||||
|
| UserMessage
|
||||||
|
| GroupedToolUseMessage
|
||||||
|
|
||||||
|
export type HookResultMessage = Message
|
||||||
|
export type SystemThinkingMessage = Message & { type: 'system' }
|
||||||
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// System prompt branded type.
|
||||||
|
// Dependency-free so it can be imported from anywhere without circular imports.
|
||||||
|
|
||||||
|
export type SystemPrompt = readonly string[] & {
|
||||||
|
readonly __brand: 'SystemPrompt'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||||
|
return value as SystemPrompt
|
||||||
|
}
|
||||||
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Usage types for the model provider package.
|
||||||
|
// Moved from src/entrypoints/sdk/sdkUtilityTypes.ts and src/services/api/emptyUsage.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-nullable usage object representing token consumption from an API response.
|
||||||
|
* Moved from src/entrypoints/sdk/sdkUtilityTypes.ts
|
||||||
|
*/
|
||||||
|
export type NonNullableUsage = {
|
||||||
|
inputTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
cacheReadInputTokens?: number
|
||||||
|
cacheCreationInputTokens?: number
|
||||||
|
input_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
|
||||||
|
service_tier: string
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_1h_input_tokens: number
|
||||||
|
ephemeral_5m_input_tokens: number
|
||||||
|
}
|
||||||
|
inference_geo: string
|
||||||
|
iterations: unknown[]
|
||||||
|
speed: string
|
||||||
|
cache_deleted_input_tokens?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero-initialized usage object. Extracted from logging.ts so that
|
||||||
|
* bridge/replBridge.ts can import it without transitively pulling in
|
||||||
|
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
|
||||||
|
*/
|
||||||
|
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
||||||
|
input_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
||||||
|
service_tier: 'standard',
|
||||||
|
cache_creation: {
|
||||||
|
ephemeral_1h_input_tokens: 0,
|
||||||
|
ephemeral_5m_input_tokens: 0,
|
||||||
|
},
|
||||||
|
inference_geo: '',
|
||||||
|
iterations: [],
|
||||||
|
speed: 'standard',
|
||||||
|
}
|
||||||
7
packages/@ant/model-provider/tsconfig.json
Normal file
7
packages/@ant/model-provider/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ mock.module("src/utils/model/agent.js", () => ({
|
|||||||
|
|
||||||
mock.module("src/utils/settings/constants.js", () => ({
|
mock.module("src/utils/settings/constants.js", () => ({
|
||||||
getSourceDisplayName: (source: string) => source,
|
getSourceDisplayName: (source: string) => source,
|
||||||
|
getSourceDisplayNameLowercase: (source: string) => source,
|
||||||
|
getSourceDisplayNameCapitalized: (source: string) => source,
|
||||||
|
getSettingSourceName: (source: string) => source,
|
||||||
|
getSettingSourceDisplayNameLowercase: (source: string) => source,
|
||||||
|
getSettingSourceDisplayNameCapitalized: (source: string) => source,
|
||||||
|
parseSettingSourcesFlag: () => [],
|
||||||
|
getEnabledSettingSources: () => [],
|
||||||
|
isSettingSourceEnabled: () => true,
|
||||||
|
SETTING_SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||||
|
SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||||
|
CLAUDE_CODE_SETTINGS_SCHEMA_URL: "https://json.schemastore.org/claude-code-settings.json",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ mock.module("src/utils/cwd.js", () => ({
|
|||||||
getCwd: () => mockCwd,
|
getCwd: () => mockCwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||||
|
mock.module("src/utils/powershell/parser.js", () => ({
|
||||||
|
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||||
|
COMMON_ALIASES: {},
|
||||||
|
commandHasArgAbbreviation: () => false,
|
||||||
|
deriveSecurityFlags: () => ({}),
|
||||||
|
getAllCommands: () => [],
|
||||||
|
getVariablesByScope: () => [],
|
||||||
|
hasCommandNamed: () => false,
|
||||||
|
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||||
|
}))
|
||||||
|
|
||||||
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
||||||
|
|
||||||
describe("isGitInternalPathPS", () => {
|
describe("isGitInternalPathPS", () => {
|
||||||
|
|||||||
@@ -32,6 +32,58 @@ mock.module("src/utils/powershell/dangerousCmdlets.js", () => ({
|
|||||||
]),
|
]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||||
|
// Provide parser stubs so powershellSecurity.ts loads without the alias.
|
||||||
|
// The tests build ParsedPowerShellCommand objects manually via makeParsed(),
|
||||||
|
// so the real parser implementations are not needed for these specific tests.
|
||||||
|
const MOCK_COMMON_ALIASES: Record<string, string> = {
|
||||||
|
iex: "Invoke-Expression",
|
||||||
|
ii: "Invoke-Item",
|
||||||
|
sal: "Set-Alias",
|
||||||
|
ipmo: "Import-Module",
|
||||||
|
iwmi: "Invoke-WmiMethod",
|
||||||
|
saps: "Start-Process",
|
||||||
|
start: "Start-Process",
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("src/utils/powershell/parser.js", () => ({
|
||||||
|
COMMON_ALIASES: MOCK_COMMON_ALIASES,
|
||||||
|
commandHasArgAbbreviation: (cmd: any, fullParam: string, minPrefix: string) => {
|
||||||
|
const fullLower = fullParam.toLowerCase()
|
||||||
|
const prefixLower = minPrefix.toLowerCase()
|
||||||
|
return cmd.args.some((a: string) => {
|
||||||
|
const lower = a.toLowerCase()
|
||||||
|
const colonIdx = lower.indexOf(':')
|
||||||
|
const paramPart = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
|
||||||
|
return paramPart.startsWith(prefixLower) && fullLower.startsWith(paramPart)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deriveSecurityFlags: () => ({ hasRedirectToVariable: false, hasPipelineVariable: false, hasFormatHex: false, hasScriptBlocks: false, hasSubExpressions: false, hasExpandableStrings: false, hasSplatting: false, hasStopParsing: false, hasMemberInvocations: false, hasAssignments: false }),
|
||||||
|
getAllCommands: (parsed: any) => parsed.statements.flatMap((s: any) => s.commands || []),
|
||||||
|
getVariablesByScope: () => [],
|
||||||
|
hasCommandNamed: (parsed: any, name: string) => {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
const canonicalFromAlias = MOCK_COMMON_ALIASES[lower]?.toLowerCase()
|
||||||
|
return parsed.statements.some((s: any) => (s.commands || []).some((c: any) => {
|
||||||
|
const cmdLower = c.name.toLowerCase()
|
||||||
|
if (cmdLower === lower) return true
|
||||||
|
const canonical = MOCK_COMMON_ALIASES[cmdLower]?.toLowerCase()
|
||||||
|
if (canonical === lower) return true
|
||||||
|
if (canonicalFromAlias && cmdLower === canonicalFromAlias) return true
|
||||||
|
return false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||||
|
PARSE_SCRIPT_BODY: "",
|
||||||
|
WINDOWS_MAX_COMMAND_LENGTH: 32000,
|
||||||
|
MAX_COMMAND_LENGTH: 32000,
|
||||||
|
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||||
|
mapStatementType: (t: string) => t,
|
||||||
|
mapElementType: (t: string) => t,
|
||||||
|
classifyCommandName: () => ({ type: 'external', name: '' }),
|
||||||
|
stripModulePrefix: (n: string) => n,
|
||||||
|
}));
|
||||||
|
|
||||||
// Real parser functions work without mocks since they're pure
|
// Real parser functions work without mocks since they're pure
|
||||||
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ let isFirstPartyBaseUrl = true
|
|||||||
// Only mock the external dependency that controls adapter selection
|
// Only mock the external dependency that controls adapter selection
|
||||||
mock.module('src/utils/model/providers.js', () => ({
|
mock.module('src/utils/model/providers.js', () => ({
|
||||||
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
||||||
|
getAPIProvider: () => 'firstParty',
|
||||||
|
getAPIProviderForStatsig: () => 'firstParty',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { createAdapter } = await import('../adapters/index')
|
const { createAdapter } = await import('../adapters/index')
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test'
|
import { describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
|
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
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 originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
|
||||||
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist", "web"]
|
"exclude": ["node_modules", "dist", "web"]
|
||||||
}
|
}
|
||||||
|
|||||||
15
packages/tsconfig.json
Normal file
15
packages/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["bun", "@types/node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import type {
|
|||||||
} from 'src/entrypoints/agentSdkTypes.js'
|
} from 'src/entrypoints/agentSdkTypes.js'
|
||||||
import type { BetaMessageDeltaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
import type { BetaMessageDeltaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
||||||
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
import type { NonNullableUsage } from '@ant/model-provider'
|
||||||
import { EMPTY_USAGE } from 'src/services/api/logging.js'
|
import { EMPTY_USAGE } from '@ant/model-provider'
|
||||||
import stripAnsi from 'strip-ansi'
|
import stripAnsi from 'strip-ansi'
|
||||||
import type { Command } from './commands.js'
|
import type { Command } from './commands.js'
|
||||||
import { getSlashCommandToolSkills } from './commands.js'
|
import { getSlashCommandToolSkills } from './commands.js'
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
} from '../entrypoints/sdk/controlTypes.js'
|
} from '../entrypoints/sdk/controlTypes.js'
|
||||||
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
||||||
import { logEvent } from '../services/analytics/index.js'
|
import { logEvent } from '../services/analytics/index.js'
|
||||||
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
import { EMPTY_USAGE } from '@ant/model-provider'
|
||||||
import type { Message } from '../types/message.js'
|
import type { Message } from '../types/message.js'
|
||||||
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
logEvent,
|
logEvent,
|
||||||
} from '../../services/analytics/index.js'
|
} from '../../services/analytics/index.js'
|
||||||
import { getSSLErrorHint } from '../../services/api/errorUtils.js'
|
import { getSSLErrorHint } from '@ant/model-provider'
|
||||||
import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js'
|
import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js'
|
||||||
import {
|
import {
|
||||||
createAndStoreApiKey,
|
createAndStoreApiKey,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
registerProcessOutputErrorHandlers,
|
registerProcessOutputErrorHandlers,
|
||||||
} from 'src/utils/process.js'
|
} from 'src/utils/process.js'
|
||||||
import type { Stream } from 'src/utils/stream.js'
|
import type { Stream } from 'src/utils/stream.js'
|
||||||
import { EMPTY_USAGE } from 'src/services/api/logging.js'
|
import { EMPTY_USAGE } from '@ant/model-provider'
|
||||||
import {
|
import {
|
||||||
loadConversationForResume,
|
loadConversationForResume,
|
||||||
type TurnInterruptionState,
|
type TurnInterruptionState,
|
||||||
|
|||||||
93
src/commands/poor/__tests__/poorMode.test.ts
Normal file
93
src/commands/poor/__tests__/poorMode.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Tests for fix: 修复穷鬼模式的写入问题
|
||||||
|
*
|
||||||
|
* Before the fix, poorMode was an in-memory boolean that reset on restart.
|
||||||
|
* After the fix, it reads from / writes to settings.json via
|
||||||
|
* getInitialSettings() and updateSettingsForSource().
|
||||||
|
*/
|
||||||
|
import { describe, expect, test, beforeEach, mock } from 'bun:test'
|
||||||
|
|
||||||
|
// ── Mocks must be declared before the module under test is imported ──────────
|
||||||
|
|
||||||
|
let mockSettings: Record<string, unknown> = {}
|
||||||
|
let lastUpdate: { source: string; patch: Record<string, unknown> } | null = null
|
||||||
|
|
||||||
|
mock.module('src/utils/settings/settings.js', () => ({
|
||||||
|
getInitialSettings: () => mockSettings,
|
||||||
|
updateSettingsForSource: (source: string, patch: Record<string, unknown>) => {
|
||||||
|
lastUpdate = { source, patch }
|
||||||
|
mockSettings = { ...mockSettings, ...patch }
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import AFTER mocks are registered
|
||||||
|
const { isPoorModeActive, setPoorMode } = await import('../poorMode.js')
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Reset module-level singleton between tests by re-importing a fresh copy. */
|
||||||
|
async function freshModule() {
|
||||||
|
// Bun caches modules; we manipulate the exported functions directly since
|
||||||
|
// the singleton `poorModeActive` is reset to null only on first import.
|
||||||
|
// Instead we test the observable behaviour through set/get pairs.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('isPoorModeActive — reads from settings on first call', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lastUpdate = null
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when settings has no poorMode key', () => {
|
||||||
|
mockSettings = {}
|
||||||
|
// Force re-read by setting internal state via setPoorMode then checking
|
||||||
|
setPoorMode(false)
|
||||||
|
expect(isPoorModeActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true when settings.poorMode === true', () => {
|
||||||
|
mockSettings = { poorMode: true }
|
||||||
|
setPoorMode(true)
|
||||||
|
expect(isPoorModeActive()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setPoorMode — persists to settings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
lastUpdate = null
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setPoorMode(true) calls updateSettingsForSource with poorMode: true', () => {
|
||||||
|
setPoorMode(true)
|
||||||
|
expect(lastUpdate).not.toBeNull()
|
||||||
|
expect(lastUpdate!.source).toBe('userSettings')
|
||||||
|
expect(lastUpdate!.patch.poorMode).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setPoorMode(false) calls updateSettingsForSource with poorMode: undefined (removes key)', () => {
|
||||||
|
setPoorMode(false)
|
||||||
|
expect(lastUpdate).not.toBeNull()
|
||||||
|
expect(lastUpdate!.source).toBe('userSettings')
|
||||||
|
// false || undefined === undefined — key should be removed to keep settings clean
|
||||||
|
expect(lastUpdate!.patch.poorMode).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isPoorModeActive() reflects the value set by setPoorMode()', () => {
|
||||||
|
setPoorMode(true)
|
||||||
|
expect(isPoorModeActive()).toBe(true)
|
||||||
|
|
||||||
|
setPoorMode(false)
|
||||||
|
expect(isPoorModeActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toggling multiple times stays consistent', () => {
|
||||||
|
setPoorMode(true)
|
||||||
|
setPoorMode(true)
|
||||||
|
expect(isPoorModeActive()).toBe(true)
|
||||||
|
|
||||||
|
setPoorMode(false)
|
||||||
|
setPoorMode(false)
|
||||||
|
expect(isPoorModeActive()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,7 @@ import { installOAuthTokens } from '../cli/handlers/auth.js'
|
|||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||||
import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink'
|
import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink'
|
||||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||||
import { getSSLErrorHint } from '../services/api/errorUtils.js'
|
import { getSSLErrorHint } from '@ant/model-provider'
|
||||||
import { sendNotification } from '../services/notifier.js'
|
import { sendNotification } from '../services/notifier.js'
|
||||||
import { OAuthService } from '../services/oauth/index.js'
|
import { OAuthService } from '../services/oauth/index.js'
|
||||||
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
|
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import { formatAPIError } from 'src/services/api/errorUtils.js'
|
import { formatAPIError } from '@ant/model-provider'
|
||||||
import type { SystemAPIErrorMessage } from 'src/types/message.js'
|
import type { SystemAPIErrorMessage } from 'src/types/message.js'
|
||||||
import { useInterval } from 'usehooks-ts'
|
import { useInterval } from 'usehooks-ts'
|
||||||
import { CtrlOToExpand } from '../CtrlOToExpand.js'
|
import { CtrlOToExpand } from '../CtrlOToExpand.js'
|
||||||
|
|||||||
@@ -1,24 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Stub: SDK Utility Types.
|
* Stub: SDK Utility Types.
|
||||||
|
* Re-exported from @ant/model-provider.
|
||||||
*/
|
*/
|
||||||
export type NonNullableUsage = {
|
export type { NonNullableUsage } from '@ant/model-provider'
|
||||||
inputTokens?: number
|
|
||||||
outputTokens?: number
|
|
||||||
cacheReadInputTokens?: number
|
|
||||||
cacheCreationInputTokens?: number
|
|
||||||
input_tokens: number
|
|
||||||
cache_creation_input_tokens: number
|
|
||||||
cache_read_input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
|
|
||||||
service_tier: string
|
|
||||||
cache_creation: {
|
|
||||||
ephemeral_1h_input_tokens: number
|
|
||||||
ephemeral_5m_input_tokens: number
|
|
||||||
}
|
|
||||||
inference_geo: string
|
|
||||||
iterations: unknown[]
|
|
||||||
speed: string
|
|
||||||
cache_deleted_input_tokens?: number
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|||||||
74
src/keybindings/__tests__/confirmation-keybindings.test.ts
Normal file
74
src/keybindings/__tests__/confirmation-keybindings.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Tests for fix: 修复 n 快捷键导致关闭的问题
|
||||||
|
*
|
||||||
|
* Before the fix, 'y' and 'n' were bound to confirm:yes / confirm:no in the
|
||||||
|
* Confirmation context, which caused accidental dismissal when typing those
|
||||||
|
* letters in other inputs. The fix removed those bindings, keeping only
|
||||||
|
* enter/escape.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { DEFAULT_BINDINGS } from '../defaultBindings.js'
|
||||||
|
import { parseBindings } from '../parser.js'
|
||||||
|
import { resolveKey } from '@anthropic/ink'
|
||||||
|
import type { Key } from '@anthropic/ink'
|
||||||
|
|
||||||
|
function makeKey(overrides: Partial<Key> = {}): Key {
|
||||||
|
return {
|
||||||
|
upArrow: false,
|
||||||
|
downArrow: false,
|
||||||
|
leftArrow: false,
|
||||||
|
rightArrow: false,
|
||||||
|
pageDown: false,
|
||||||
|
pageUp: false,
|
||||||
|
wheelUp: false,
|
||||||
|
wheelDown: false,
|
||||||
|
home: false,
|
||||||
|
end: false,
|
||||||
|
return: false,
|
||||||
|
escape: false,
|
||||||
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
|
fn: false,
|
||||||
|
tab: false,
|
||||||
|
backspace: false,
|
||||||
|
delete: false,
|
||||||
|
meta: false,
|
||||||
|
super: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = parseBindings(DEFAULT_BINDINGS)
|
||||||
|
|
||||||
|
describe('Confirmation context — n/y keys removed (fix: 修复 n 快捷键导致关闭的问题)', () => {
|
||||||
|
test('pressing "n" in Confirmation context should NOT resolve to confirm:no', () => {
|
||||||
|
const result = resolveKey('n', makeKey(), ['Confirmation'], bindings)
|
||||||
|
if (result.type === 'match') {
|
||||||
|
expect(result.action).not.toBe('confirm:no')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pressing "y" in Confirmation context should NOT resolve to confirm:yes', () => {
|
||||||
|
const result = resolveKey('y', makeKey(), ['Confirmation'], bindings)
|
||||||
|
if (result.type === 'match') {
|
||||||
|
expect(result.action).not.toBe('confirm:yes')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pressing Enter in Confirmation context resolves to confirm:yes', () => {
|
||||||
|
const result = resolveKey('', makeKey({ return: true }), ['Confirmation'], bindings)
|
||||||
|
expect(result).toEqual({ type: 'match', action: 'confirm:yes' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pressing Escape in Confirmation context resolves to confirm:no', () => {
|
||||||
|
const result = resolveKey('', makeKey({ escape: true }), ['Confirmation'], bindings)
|
||||||
|
expect(result).toEqual({ type: 'match', action: 'confirm:no' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('"n" does not accidentally close dialogs in Chat context', () => {
|
||||||
|
const result = resolveKey('n', makeKey(), ['Chat'], bindings)
|
||||||
|
if (result.type === 'match') {
|
||||||
|
expect(result.action).not.toBe('confirm:no')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,12 +20,23 @@ mock.module('../../../tools.js', () => ({
|
|||||||
|
|
||||||
mock.module('../../../Tool.js', () => ({
|
mock.module('../../../Tool.js', () => ({
|
||||||
getEmptyToolPermissionContext: mock(() => ({})),
|
getEmptyToolPermissionContext: mock(() => ({})),
|
||||||
|
toolMatchesName: mock(() => false),
|
||||||
|
findToolByName: mock(() => undefined),
|
||||||
|
filterToolProgressMessages: mock(() => []),
|
||||||
|
buildTool: mock((def: any) => def),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../utils/config.js', () => ({
|
mock.module('../../../utils/config.js', () => ({
|
||||||
enableConfigs: mock(() => {}),
|
enableConfigs: mock(() => {}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Also mock via src/ alias to prevent alias resolution corruption for other test files.
|
||||||
|
// See: agent.test.ts's relative-path mock for config.js breaks Bun's src/* path
|
||||||
|
// alias for subsequent test files (Cannot find module 'src/utils/errors.js' etc.)
|
||||||
|
mock.module('src/utils/config.js', () => ({
|
||||||
|
enableConfigs: mock(() => {}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module('../../../bootstrap/state.js', () => ({
|
mock.module('../../../bootstrap/state.js', () => ({
|
||||||
setOriginalCwd: mock(() => {}),
|
setOriginalCwd: mock(() => {}),
|
||||||
addSlowOperation: mock(() => {}),
|
addSlowOperation: mock(() => {}),
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
|
// Re-export EMPTY_USAGE from @ant/model-provider
|
||||||
|
// Kept here for backward compatibility — consumers import from this path.
|
||||||
/**
|
export { EMPTY_USAGE } from '@ant/model-provider'
|
||||||
* Zero-initialized usage object. Extracted from logging.ts so that
|
export type { NonNullableUsage } from '@ant/model-provider'
|
||||||
* bridge/replBridge.ts can import it without transitively pulling in
|
|
||||||
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
|
|
||||||
*/
|
|
||||||
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
|
||||||
input_tokens: 0,
|
|
||||||
cache_creation_input_tokens: 0,
|
|
||||||
cache_read_input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
|
||||||
service_tier: 'standard',
|
|
||||||
cache_creation: {
|
|
||||||
ephemeral_1h_input_tokens: 0,
|
|
||||||
ephemeral_5m_input_tokens: 0,
|
|
||||||
},
|
|
||||||
inference_geo: '',
|
|
||||||
iterations: [],
|
|
||||||
speed: 'standard',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,260 +1,8 @@
|
|||||||
import type { APIError } from '@anthropic-ai/sdk'
|
// Re-export from @ant/model-provider
|
||||||
|
export {
|
||||||
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
|
formatAPIError,
|
||||||
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
|
extractConnectionErrorDetails,
|
||||||
const SSL_ERROR_CODES = new Set([
|
sanitizeAPIError,
|
||||||
// Certificate verification errors
|
getSSLErrorHint,
|
||||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
type ConnectionErrorDetails,
|
||||||
'UNABLE_TO_GET_ISSUER_CERT',
|
} from '@ant/model-provider'
|
||||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
||||||
'CERT_SIGNATURE_FAILURE',
|
|
||||||
'CERT_NOT_YET_VALID',
|
|
||||||
'CERT_HAS_EXPIRED',
|
|
||||||
'CERT_REVOKED',
|
|
||||||
'CERT_REJECTED',
|
|
||||||
'CERT_UNTRUSTED',
|
|
||||||
// Self-signed certificate errors
|
|
||||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
||||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
||||||
// Chain errors
|
|
||||||
'CERT_CHAIN_TOO_LONG',
|
|
||||||
'PATH_LENGTH_EXCEEDED',
|
|
||||||
// Hostname/altname errors
|
|
||||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
||||||
'HOSTNAME_MISMATCH',
|
|
||||||
// TLS handshake errors
|
|
||||||
'ERR_TLS_HANDSHAKE_TIMEOUT',
|
|
||||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
|
||||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
|
||||||
])
|
|
||||||
|
|
||||||
export type ConnectionErrorDetails = {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
isSSLError: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts connection error details from the error cause chain.
|
|
||||||
* The Anthropic SDK wraps underlying errors in the `cause` property.
|
|
||||||
* This function walks the cause chain to find the root error code/message.
|
|
||||||
*/
|
|
||||||
export function extractConnectionErrorDetails(
|
|
||||||
error: unknown,
|
|
||||||
): ConnectionErrorDetails | null {
|
|
||||||
if (!error || typeof error !== 'object') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the cause chain to find the root error with a code
|
|
||||||
let current: unknown = error
|
|
||||||
const maxDepth = 5 // Prevent infinite loops
|
|
||||||
let depth = 0
|
|
||||||
|
|
||||||
while (current && depth < maxDepth) {
|
|
||||||
if (
|
|
||||||
current instanceof Error &&
|
|
||||||
'code' in current &&
|
|
||||||
typeof current.code === 'string'
|
|
||||||
) {
|
|
||||||
const code = current.code
|
|
||||||
const isSSLError = SSL_ERROR_CODES.has(code)
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
message: current.message,
|
|
||||||
isSSLError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to the next cause in the chain
|
|
||||||
if (
|
|
||||||
current instanceof Error &&
|
|
||||||
'cause' in current &&
|
|
||||||
current.cause !== current
|
|
||||||
) {
|
|
||||||
current = current.cause
|
|
||||||
depth++
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
|
|
||||||
* the main API client (OAuth token exchange, preflight connectivity checks)
|
|
||||||
* where `formatAPIError` doesn't apply.
|
|
||||||
*
|
|
||||||
* Motivation: enterprise users behind TLS-intercepting proxies (Zscaler et al.)
|
|
||||||
* see OAuth complete in-browser but the CLI's token exchange silently fails
|
|
||||||
* with a raw SSL code. Surfacing the likely fix saves a support round-trip.
|
|
||||||
*/
|
|
||||||
export function getSSLErrorHint(error: unknown): string | null {
|
|
||||||
const details = extractConnectionErrorDetails(error)
|
|
||||||
if (!details?.isSSLError) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
|
|
||||||
* returning a user-friendly title or empty string if HTML is detected.
|
|
||||||
* Returns the original message unchanged if no HTML is found.
|
|
||||||
*/
|
|
||||||
function sanitizeMessageHTML(message: string): string {
|
|
||||||
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
|
|
||||||
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
|
|
||||||
if (titleMatch && titleMatch[1]) {
|
|
||||||
return titleMatch[1].trim()
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
|
|
||||||
* and returns a user-friendly message instead
|
|
||||||
*/
|
|
||||||
export function sanitizeAPIError(apiError: APIError): string {
|
|
||||||
const message = apiError.message
|
|
||||||
if (!message) {
|
|
||||||
// Sometimes message is undefined
|
|
||||||
// TODO: figure out why
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return sanitizeMessageHTML(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shapes of deserialized API errors from session JSONL.
|
|
||||||
*
|
|
||||||
* After JSON round-tripping, the SDK's APIError loses its `.message` property.
|
|
||||||
* The actual message lives at different nesting levels depending on the provider:
|
|
||||||
*
|
|
||||||
* - Bedrock/proxy: `{ error: { message: "..." } }`
|
|
||||||
* - Standard Anthropic API: `{ error: { error: { message: "..." } } }`
|
|
||||||
* (the outer `.error` is the response body, the inner `.error` is the API error)
|
|
||||||
*
|
|
||||||
* See also: `getErrorMessage` in `logging.ts` which handles the same shapes.
|
|
||||||
*/
|
|
||||||
type NestedAPIError = {
|
|
||||||
error?: {
|
|
||||||
message?: string
|
|
||||||
error?: { message?: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasNestedError(value: unknown): value is NestedAPIError {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'error' in value &&
|
|
||||||
typeof value.error === 'object' &&
|
|
||||||
value.error !== null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a human-readable message from a deserialized API error that lacks
|
|
||||||
* a top-level `.message`.
|
|
||||||
*
|
|
||||||
* Checks two nesting levels (deeper first for specificity):
|
|
||||||
* 1. `error.error.error.message` — standard Anthropic API shape
|
|
||||||
* 2. `error.error.message` — Bedrock shape
|
|
||||||
*/
|
|
||||||
function extractNestedErrorMessage(error: APIError): string | null {
|
|
||||||
if (!hasNestedError(error)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Access `.error` via the narrowed type so TypeScript sees the nested shape
|
|
||||||
// instead of the SDK's `Object | undefined`.
|
|
||||||
const narrowed: NestedAPIError = error
|
|
||||||
const nested = narrowed.error
|
|
||||||
|
|
||||||
// Standard Anthropic API shape: { error: { error: { message } } }
|
|
||||||
const deepMsg = nested?.error?.message
|
|
||||||
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
|
|
||||||
const sanitized = sanitizeMessageHTML(deepMsg)
|
|
||||||
if (sanitized.length > 0) {
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bedrock shape: { error: { message } }
|
|
||||||
const msg = nested?.message
|
|
||||||
if (typeof msg === 'string' && msg.length > 0) {
|
|
||||||
const sanitized = sanitizeMessageHTML(msg)
|
|
||||||
if (sanitized.length > 0) {
|
|
||||||
return sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAPIError(error: APIError): string {
|
|
||||||
// Extract connection error details from the cause chain
|
|
||||||
const connectionDetails = extractConnectionErrorDetails(error)
|
|
||||||
|
|
||||||
if (connectionDetails) {
|
|
||||||
const { code, isSSLError } = connectionDetails
|
|
||||||
|
|
||||||
// Handle timeout errors
|
|
||||||
if (code === 'ETIMEDOUT') {
|
|
||||||
return 'Request timed out. Check your internet connection and proxy settings'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle SSL/TLS errors with specific messages
|
|
||||||
if (isSSLError) {
|
|
||||||
switch (code) {
|
|
||||||
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
|
||||||
case 'UNABLE_TO_GET_ISSUER_CERT':
|
|
||||||
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
|
||||||
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
|
|
||||||
case 'CERT_HAS_EXPIRED':
|
|
||||||
return 'Unable to connect to API: SSL certificate has expired'
|
|
||||||
case 'CERT_REVOKED':
|
|
||||||
return 'Unable to connect to API: SSL certificate has been revoked'
|
|
||||||
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
||||||
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
||||||
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
|
|
||||||
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
|
||||||
case 'HOSTNAME_MISMATCH':
|
|
||||||
return 'Unable to connect to API: SSL certificate hostname mismatch'
|
|
||||||
case 'CERT_NOT_YET_VALID':
|
|
||||||
return 'Unable to connect to API: SSL certificate is not yet valid'
|
|
||||||
default:
|
|
||||||
return `Unable to connect to API: SSL error (${code})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.message === 'Connection error.') {
|
|
||||||
// If we have a code but it's not SSL, include it for debugging
|
|
||||||
if (connectionDetails?.code) {
|
|
||||||
return `Unable to connect to API (${connectionDetails.code})`
|
|
||||||
}
|
|
||||||
return 'Unable to connect to API. Check your internet connection'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
|
|
||||||
// be a plain object without a `.message` property. Return a safe fallback
|
|
||||||
// instead of undefined, which would crash callers that access `.length`.
|
|
||||||
if (!error.message) {
|
|
||||||
return (
|
|
||||||
extractNestedErrorMessage(error) ??
|
|
||||||
`API error (status ${error.status ?? 'unknown'})`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizedMessage = sanitizeAPIError(error)
|
|
||||||
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
|
|
||||||
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
|
|
||||||
? sanitizedMessage
|
|
||||||
: error.message
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getProxyFetchOptions } from 'src/utils/proxy.js'
|
|||||||
import type {
|
import type {
|
||||||
GeminiGenerateContentRequest,
|
GeminiGenerateContentRequest,
|
||||||
GeminiStreamChunk,
|
GeminiStreamChunk,
|
||||||
} from './types.js'
|
} from '@ant/model-provider'
|
||||||
|
|
||||||
const DEFAULT_GEMINI_BASE_URL =
|
const DEFAULT_GEMINI_BASE_URL =
|
||||||
'https://generativelanguage.googleapis.com/v1beta'
|
'https://generativelanguage.googleapis.com/v1beta'
|
||||||
|
|||||||
@@ -19,14 +19,7 @@ import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
|||||||
import type { ThinkingConfig } from '../../../utils/thinking.js'
|
import type { ThinkingConfig } from '../../../utils/thinking.js'
|
||||||
import type { Options } from '../claude.js'
|
import type { Options } from '../claude.js'
|
||||||
import { streamGeminiGenerateContent } from './client.js'
|
import { streamGeminiGenerateContent } from './client.js'
|
||||||
import { anthropicMessagesToGemini } from './convertMessages.js'
|
import { anthropicMessagesToGemini, resolveGeminiModel, adaptGeminiStreamToAnthropic, anthropicToolsToGemini, anthropicToolChoiceToGemini, GEMINI_THOUGHT_SIGNATURE_FIELD } from '@ant/model-provider'
|
||||||
import {
|
|
||||||
anthropicToolChoiceToGemini,
|
|
||||||
anthropicToolsToGemini,
|
|
||||||
} from './convertTools.js'
|
|
||||||
import { resolveGeminiModel } from './modelMapping.js'
|
|
||||||
import { adaptGeminiStreamToAnthropic } from './streamAdapter.js'
|
|
||||||
import { GEMINI_THOUGHT_SIGNATURE_FIELD } from './types.js'
|
|
||||||
|
|
||||||
export async function* queryModelGemini(
|
export async function* queryModelGemini(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test'
|
||||||
|
|
||||||
|
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||||
|
mock.module('src/utils/proxy.js', () => ({
|
||||||
|
getProxyFetchOptions: () => ({} as any),
|
||||||
|
}))
|
||||||
|
|
||||||
import { getGrokClient, clearGrokClientCache } from '../client.js'
|
import { getGrokClient, clearGrokClientCache } from '../client.js'
|
||||||
|
|
||||||
describe('getGrokClient', () => {
|
describe('getGrokClient', () => {
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import type {
|
|||||||
ChatCompletionCreateParamsStreaming,
|
ChatCompletionCreateParamsStreaming,
|
||||||
} from 'openai/resources/chat/completions/completions.mjs'
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import { getGrokClient } from './client.js'
|
import { getGrokClient } from './client.js'
|
||||||
import { anthropicMessagesToOpenAI } from '../openai/convertMessages.js'
|
import { anthropicMessagesToOpenAI, anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI, adaptOpenAIStreamToAnthropic, resolveGrokModel } from '@ant/model-provider'
|
||||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openai/convertTools.js'
|
|
||||||
import { adaptOpenAIStreamToAnthropic } from '../openai/streamAdapter.js'
|
|
||||||
import { resolveGrokModel } from './modelMapping.js'
|
|
||||||
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
||||||
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
||||||
import { toolToAPISchema } from '../../../utils/api.js'
|
import { toolToAPISchema } from '../../../utils/api.js'
|
||||||
|
|||||||
@@ -1,487 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for queryModelOpenAI in index.ts.
|
|
||||||
*
|
|
||||||
* Focused on the two bugs fixed:
|
|
||||||
* 1. stop_reason was always null in the assembled AssistantMessage because
|
|
||||||
* partialMessage (from message_start) has stop_reason: null, and the
|
|
||||||
* stop_reason captured from message_delta was never applied.
|
|
||||||
* 2. partialMessage was not reset to null after message_stop, so the safety
|
|
||||||
* fallback at the end of the loop would yield a second identical
|
|
||||||
* AssistantMessage (causing doubled content in the next API request).
|
|
||||||
*
|
|
||||||
* Strategy: mock getOpenAIClient + adaptOpenAIStreamToAnthropic so we can
|
|
||||||
* feed pre-built Anthropic events directly into queryModelOpenAI and inspect
|
|
||||||
* what it emits — without any real HTTP calls.
|
|
||||||
*/
|
|
||||||
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
|
|
||||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
||||||
import type { AssistantMessage, StreamEvent } from '../../../../types/message.js'
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build a minimal message_start event */
|
|
||||||
function makeMessageStart(overrides: Record<string, any> = {}): BetaRawMessageStreamEvent {
|
|
||||||
return {
|
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: 'msg_test',
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
content: [],
|
|
||||||
model: 'test-model',
|
|
||||||
stop_reason: null,
|
|
||||||
stop_sequence: null,
|
|
||||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a content_block_start event for the given block type */
|
|
||||||
function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record<string, any> = {}): BetaRawMessageStreamEvent {
|
|
||||||
const block =
|
|
||||||
type === 'text'
|
|
||||||
? { type: 'text', text: '' }
|
|
||||||
: type === 'tool_use'
|
|
||||||
? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} }
|
|
||||||
: { type: 'thinking', thinking: '', signature: '' }
|
|
||||||
return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a text_delta content_block_delta event */
|
|
||||||
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build an input_json_delta content_block_delta event */
|
|
||||||
function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a thinking_delta content_block_delta event */
|
|
||||||
function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a content_block_stop event */
|
|
||||||
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_stop', index } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a message_delta event with stop_reason and output_tokens */
|
|
||||||
function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent {
|
|
||||||
return {
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
||||||
usage: { output_tokens: outputTokens },
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a message_stop event */
|
|
||||||
function makeMessageStop(): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'message_stop' } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Async generator from a fixed array of events */
|
|
||||||
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
|
|
||||||
for (const e of events) yield e
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect all outputs from queryModelOpenAI into typed buckets */
|
|
||||||
async function runQueryModel(
|
|
||||||
events: BetaRawMessageStreamEvent[],
|
|
||||||
envOverrides: Record<string, string | undefined> = {},
|
|
||||||
) {
|
|
||||||
// Wire events into the mocked stream adapter
|
|
||||||
_nextEvents = events
|
|
||||||
// Save + apply env overrides
|
|
||||||
const saved: Record<string, string | undefined> = {}
|
|
||||||
for (const [k, v] of Object.entries(envOverrides)) {
|
|
||||||
saved[k] = process.env[k]
|
|
||||||
if (v === undefined) delete process.env[k]
|
|
||||||
else process.env[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// We inline mock.module inside the try block.
|
|
||||||
// Bun resolves mock.module at the call site synchronously (hoisted),
|
|
||||||
// so we register once per test file, then re-import each time.
|
|
||||||
const { queryModelOpenAI } = await import('../index.js')
|
|
||||||
|
|
||||||
const assistantMessages: AssistantMessage[] = []
|
|
||||||
const streamEvents: StreamEvent[] = []
|
|
||||||
const otherOutputs: any[] = []
|
|
||||||
|
|
||||||
const minimalOptions: any = {
|
|
||||||
model: 'test-model',
|
|
||||||
tools: [],
|
|
||||||
agents: [],
|
|
||||||
querySource: 'main_loop',
|
|
||||||
getToolPermissionContext: async () => ({
|
|
||||||
alwaysAllow: [],
|
|
||||||
alwaysDeny: [],
|
|
||||||
needsPermission: [],
|
|
||||||
mode: 'default',
|
|
||||||
isBypassingPermissions: false,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const item of queryModelOpenAI(
|
|
||||||
[],
|
|
||||||
{ type: 'text', text: '' } as any,
|
|
||||||
[],
|
|
||||||
new AbortController().signal,
|
|
||||||
minimalOptions,
|
|
||||||
)) {
|
|
||||||
if (item.type === 'assistant') {
|
|
||||||
assistantMessages.push(item as AssistantMessage)
|
|
||||||
} else if (item.type === 'stream_event') {
|
|
||||||
streamEvents.push(item as StreamEvent)
|
|
||||||
} else {
|
|
||||||
otherOutputs.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { assistantMessages, streamEvents, otherOutputs }
|
|
||||||
} finally {
|
|
||||||
// Restore env
|
|
||||||
for (const [k, v] of Object.entries(saved)) {
|
|
||||||
if (v === undefined) delete process.env[k]
|
|
||||||
else process.env[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── mock setup ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// We mock at module level. Bun's mock.module replaces the module for the
|
|
||||||
// entire file, so we configure the stream per-test via a shared variable.
|
|
||||||
let _nextEvents: BetaRawMessageStreamEvent[] = []
|
|
||||||
|
|
||||||
/** Captured arguments from the last chat.completions.create() call */
|
|
||||||
let _lastCreateArgs: Record<string, any> | null = null
|
|
||||||
|
|
||||||
mock.module('../client.js', () => ({
|
|
||||||
getOpenAIClient: () => ({
|
|
||||||
chat: {
|
|
||||||
completions: {
|
|
||||||
create: async (args: Record<string, any>) => {
|
|
||||||
_lastCreateArgs = args
|
|
||||||
return { [Symbol.asyncIterator]: async function* () {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../streamAdapter.js', () => ({
|
|
||||||
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../modelMapping.js', () => ({
|
|
||||||
resolveOpenAIModel: (m: string) => m,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../convertMessages.js', () => ({
|
|
||||||
anthropicMessagesToOpenAI: () => [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../convertTools.js', () => ({
|
|
||||||
anthropicToolsToOpenAI: () => [],
|
|
||||||
anthropicToolChoiceToOpenAI: () => undefined,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/context.js', () => ({
|
|
||||||
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
|
|
||||||
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
|
|
||||||
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
|
|
||||||
ESCALATED_MAX_TOKENS: 64_000,
|
|
||||||
is1mContextDisabled: () => false,
|
|
||||||
has1mContext: () => false,
|
|
||||||
modelSupports1M: () => false,
|
|
||||||
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
|
|
||||||
getContextWindowForModel: () => 200_000,
|
|
||||||
getSonnet1mExpTreatmentEnabled: () => false,
|
|
||||||
calculateContextPercentages: () => ({ usedPercent: 0, remainingPercent: 100 }),
|
|
||||||
getMaxThinkingTokensForModel: () => 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/messages.js', () => ({
|
|
||||||
normalizeMessagesForAPI: (msgs: any) => msgs,
|
|
||||||
normalizeContentFromAPI: (blocks: any[]) => blocks,
|
|
||||||
createAssistantAPIErrorMessage: (opts: any) => ({
|
|
||||||
type: 'assistant',
|
|
||||||
message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError },
|
|
||||||
uuid: 'error-uuid',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/api.js', () => ({
|
|
||||||
toolToAPISchema: async (t: any) => t,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/toolSearch.js', () => ({
|
|
||||||
isToolSearchEnabled: async () => false,
|
|
||||||
extractDiscoveredToolNames: () => new Set(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
|
|
||||||
isDeferredTool: () => false,
|
|
||||||
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../cost-tracker.js', () => ({
|
|
||||||
addToTotalSessionCost: () => {},
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/modelCost.js', () => ({
|
|
||||||
COST_TIER_3_15: {},
|
|
||||||
COST_TIER_15_75: {},
|
|
||||||
COST_TIER_5_25: {},
|
|
||||||
COST_TIER_30_150: {},
|
|
||||||
COST_HAIKU_35: {},
|
|
||||||
COST_HAIKU_45: {},
|
|
||||||
getOpus46CostTier: () => ({}),
|
|
||||||
MODEL_COSTS: {},
|
|
||||||
getModelCosts: () => ({}),
|
|
||||||
calculateUSDCost: () => 0,
|
|
||||||
calculateCostFromTokens: () => 0,
|
|
||||||
formatModelPricing: () => '',
|
|
||||||
getModelPricingString: () => undefined,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/debug.js', () => ({
|
|
||||||
logForDebugging: () => {},
|
|
||||||
logAntError: () => {},
|
|
||||||
isDebugMode: () => false,
|
|
||||||
isDebugToStdErr: () => false,
|
|
||||||
getDebugFilePath: () => null,
|
|
||||||
getDebugLogPath: () => '',
|
|
||||||
getDebugFilter: () => null,
|
|
||||||
getMinDebugLogLevel: () => 'debug',
|
|
||||||
enableDebugLogging: () => false,
|
|
||||||
setHasFormattedOutput: () => {},
|
|
||||||
getHasFormattedOutput: () => false,
|
|
||||||
flushDebugLogs: async () => {},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — stop_reason propagation', () => {
|
|
||||||
test('assembled AssistantMessage has stop_reason end_turn (not null)', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'Hello'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 10),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('assembled AssistantMessage has stop_reason tool_use', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'tool_use'),
|
|
||||||
makeInputJsonDelta(0, '{"cmd":"ls"}'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('tool_use', 20),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBe('tool_use')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('assembled AssistantMessage has stop_reason max_tokens', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'truncated'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('max_tokens', 8192),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Two assistant-typed items: the content message + the max_output_tokens error signal.
|
|
||||||
// The error signal is emitted as a synthetic assistant message by createAssistantAPIErrorMessage.
|
|
||||||
expect(assistantMessages).toHaveLength(2)
|
|
||||||
const contentMsg = assistantMessages[0]!
|
|
||||||
expect(contentMsg.message.stop_reason).toBe('max_tokens')
|
|
||||||
// Second item is the error signal (has apiError set)
|
|
||||||
const errorMsg = assistantMessages[1]!.message as any
|
|
||||||
expect(errorMsg.apiError).toBe('max_output_tokens')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stop_reason is null when no message_delta was received (safety fallback path)', async () => {
|
|
||||||
// Stream ends without message_stop — triggers the safety fallback branch.
|
|
||||||
// stop_reason stays null since no message_delta was ever seen.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'partial'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
// No message_delta / message_stop
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Safety fallback should yield the partial content
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — usage accumulation', () => {
|
|
||||||
test('usage in assembled message reflects all four fields from message_delta', async () => {
|
|
||||||
// message_start has all fields=0 (trailing-chunk pattern: usage not yet available).
|
|
||||||
// message_delta carries the real values after stream ends.
|
|
||||||
// The spread in the message_delta handler must override all zeros from message_start,
|
|
||||||
// including cache_read_input_tokens which was previously missing from message_delta.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'response'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
// message_delta carries all four Anthropic usage fields (as emitted by the fixed streamAdapter)
|
|
||||||
{
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
|
||||||
usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 },
|
|
||||||
} as any,
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
const usage = assistantMessages[0]!.message.usage as any
|
|
||||||
expect(usage.input_tokens).toBe(30011)
|
|
||||||
expect(usage.output_tokens).toBe(190)
|
|
||||||
// cache_read_input_tokens from message_delta overrides the 0 from message_start
|
|
||||||
expect(usage.cache_read_input_tokens).toBe(19904)
|
|
||||||
expect(usage.cache_creation_input_tokens).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('usage is zero when no usage events arrive (prevents false autocompact)', async () => {
|
|
||||||
// If usage stays 0, tokenCountWithEstimation will undercount — so at least
|
|
||||||
// verify the field exists and is numeric (to detect regressions).
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 0),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
const usage = assistantMessages[0]!.message.usage as any
|
|
||||||
expect(typeof usage.input_tokens).toBe('number')
|
|
||||||
expect(typeof usage.output_tokens).toBe('number')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — no duplicate AssistantMessage (partialMessage reset)', () => {
|
|
||||||
test('yields exactly one AssistantMessage per message_stop when content is present', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'only once'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Before the fix, partialMessage was not reset to null, so the safety
|
|
||||||
// fallback at the end of the loop would yield a second message with the
|
|
||||||
// same message.id — causing mergeAssistantMessages to concatenate content.
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('thinking + text response yields exactly one AssistantMessage', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'thinking'),
|
|
||||||
makeThinkingDelta(0, 'let me think'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeContentBlockStart(1, 'text'),
|
|
||||||
makeTextDelta(1, 'answer'),
|
|
||||||
makeContentBlockStop(1),
|
|
||||||
makeMessageDelta('end_turn', 30),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('safety fallback path still yields message when stream ends without message_stop', async () => {
|
|
||||||
// Simulates a stream that cuts off without the normal termination sequence.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'abrupt end'),
|
|
||||||
// No content_block_stop, no message_delta, no message_stop
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — stream_events forwarded', () => {
|
|
||||||
test('every adapted event is also yielded as stream_event for real-time display', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hello'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { streamEvents } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
const eventTypes = streamEvents.map(e => (e as any).event?.type)
|
|
||||||
expect(eventTypes).toContain('message_start')
|
|
||||||
expect(eventTypes).toContain('content_block_start')
|
|
||||||
expect(eventTypes).toContain('content_block_delta')
|
|
||||||
expect(eventTypes).toContain('content_block_stop')
|
|
||||||
expect(eventTypes).toContain('message_delta')
|
|
||||||
expect(eventTypes).toContain('message_stop')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — max_tokens forwarded to request', () => {
|
|
||||||
test('buildOpenAIRequestBody includes max_tokens in the request payload', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(_lastCreateArgs).not.toBeNull()
|
|
||||||
expect(_lastCreateArgs!.max_tokens).toBe(8192)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for queryModelOpenAI in index.ts.
|
|
||||||
*
|
|
||||||
* Focused on the two bugs fixed:
|
|
||||||
* 1. stop_reason was always null in the assembled AssistantMessage because
|
|
||||||
* partialMessage (from message_start) has stop_reason: null, and the
|
|
||||||
* stop_reason captured from message_delta was never applied.
|
|
||||||
* 2. partialMessage was not reset to null after message_stop, so the safety
|
|
||||||
* fallback at the end of the loop would yield a second identical
|
|
||||||
* AssistantMessage (causing doubled content in the next API request).
|
|
||||||
*
|
|
||||||
* Strategy: mock getOpenAIClient + adaptOpenAIStreamToAnthropic so we can
|
|
||||||
* feed pre-built Anthropic events directly into queryModelOpenAI and inspect
|
|
||||||
* what it emits — without any real HTTP calls.
|
|
||||||
*/
|
|
||||||
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
|
|
||||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
||||||
import type { AssistantMessage, StreamEvent } from '../../../../types/message.js'
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build a minimal message_start event */
|
|
||||||
function makeMessageStart(overrides: Record<string, any> = {}): BetaRawMessageStreamEvent {
|
|
||||||
return {
|
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: 'msg_test',
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
content: [],
|
|
||||||
model: 'test-model',
|
|
||||||
stop_reason: null,
|
|
||||||
stop_sequence: null,
|
|
||||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a content_block_start event for the given block type */
|
|
||||||
function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record<string, any> = {}): BetaRawMessageStreamEvent {
|
|
||||||
const block =
|
|
||||||
type === 'text'
|
|
||||||
? { type: 'text', text: '' }
|
|
||||||
: type === 'tool_use'
|
|
||||||
? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} }
|
|
||||||
: { type: 'thinking', thinking: '', signature: '' }
|
|
||||||
return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a text_delta content_block_delta event */
|
|
||||||
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build an input_json_delta content_block_delta event */
|
|
||||||
function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a thinking_delta content_block_delta event */
|
|
||||||
function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a content_block_stop event */
|
|
||||||
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'content_block_stop', index } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a message_delta event with stop_reason and output_tokens */
|
|
||||||
function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent {
|
|
||||||
return {
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
||||||
usage: { output_tokens: outputTokens },
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a message_stop event */
|
|
||||||
function makeMessageStop(): BetaRawMessageStreamEvent {
|
|
||||||
return { type: 'message_stop' } as any
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Async generator from a fixed array of events */
|
|
||||||
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
|
|
||||||
for (const e of events) yield e
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect all outputs from queryModelOpenAI into typed buckets */
|
|
||||||
async function runQueryModel(
|
|
||||||
events: BetaRawMessageStreamEvent[],
|
|
||||||
envOverrides: Record<string, string | undefined> = {},
|
|
||||||
) {
|
|
||||||
// Wire events into the mocked stream adapter
|
|
||||||
_nextEvents = events
|
|
||||||
// Save + apply env overrides
|
|
||||||
const saved: Record<string, string | undefined> = {}
|
|
||||||
for (const [k, v] of Object.entries(envOverrides)) {
|
|
||||||
saved[k] = process.env[k]
|
|
||||||
if (v === undefined) delete process.env[k]
|
|
||||||
else process.env[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// We inline mock.module inside the try block.
|
|
||||||
// Bun resolves mock.module at the call site synchronously (hoisted),
|
|
||||||
// so we register once per test file, then re-import each time.
|
|
||||||
const { queryModelOpenAI } = await import('../index.js')
|
|
||||||
|
|
||||||
const assistantMessages: AssistantMessage[] = []
|
|
||||||
const streamEvents: StreamEvent[] = []
|
|
||||||
const otherOutputs: any[] = []
|
|
||||||
|
|
||||||
const minimalOptions: any = {
|
|
||||||
model: 'test-model',
|
|
||||||
tools: [],
|
|
||||||
agents: [],
|
|
||||||
querySource: 'main_loop',
|
|
||||||
getToolPermissionContext: async () => ({
|
|
||||||
alwaysAllow: [],
|
|
||||||
alwaysDeny: [],
|
|
||||||
needsPermission: [],
|
|
||||||
mode: 'default',
|
|
||||||
isBypassingPermissions: false,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const item of queryModelOpenAI(
|
|
||||||
[],
|
|
||||||
{ type: 'text', text: '' } as any,
|
|
||||||
[],
|
|
||||||
new AbortController().signal,
|
|
||||||
minimalOptions,
|
|
||||||
)) {
|
|
||||||
if (item.type === 'assistant') {
|
|
||||||
assistantMessages.push(item as AssistantMessage)
|
|
||||||
} else if (item.type === 'stream_event') {
|
|
||||||
streamEvents.push(item as StreamEvent)
|
|
||||||
} else {
|
|
||||||
otherOutputs.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { assistantMessages, streamEvents, otherOutputs }
|
|
||||||
} finally {
|
|
||||||
// Restore env
|
|
||||||
for (const [k, v] of Object.entries(saved)) {
|
|
||||||
if (v === undefined) delete process.env[k]
|
|
||||||
else process.env[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── mock setup ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// We mock at module level. Bun's mock.module replaces the module for the
|
|
||||||
// entire file, so we configure the stream per-test via a shared variable.
|
|
||||||
let _nextEvents: BetaRawMessageStreamEvent[] = []
|
|
||||||
|
|
||||||
/** Captured arguments from the last chat.completions.create() call */
|
|
||||||
let _lastCreateArgs: Record<string, any> | null = null
|
|
||||||
|
|
||||||
mock.module('../client.js', () => ({
|
|
||||||
getOpenAIClient: () => ({
|
|
||||||
chat: {
|
|
||||||
completions: {
|
|
||||||
create: async (args: Record<string, any>) => {
|
|
||||||
_lastCreateArgs = args
|
|
||||||
return { [Symbol.asyncIterator]: async function* () {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../streamAdapter.js', () => ({
|
|
||||||
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../modelMapping.js', () => ({
|
|
||||||
resolveOpenAIModel: (m: string) => m,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../convertMessages.js', () => ({
|
|
||||||
anthropicMessagesToOpenAI: () => [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../convertTools.js', () => ({
|
|
||||||
anthropicToolsToOpenAI: () => [],
|
|
||||||
anthropicToolChoiceToOpenAI: () => undefined,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/context.js', () => ({
|
|
||||||
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
|
|
||||||
getContextWindowForModel: () => 200_000,
|
|
||||||
modelSupports1M: () => false,
|
|
||||||
has1mContext: () => false,
|
|
||||||
is1mContextDisabled: () => false,
|
|
||||||
getSonnet1mExpTreatmentEnabled: () => false,
|
|
||||||
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
|
|
||||||
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
|
|
||||||
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
|
|
||||||
ESCALATED_MAX_TOKENS: 64_000,
|
|
||||||
calculateContextPercentages: () => ({ used: null, remaining: null }),
|
|
||||||
getMaxThinkingTokensForModel: () => 8191,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/messages.js', () => ({
|
|
||||||
normalizeMessagesForAPI: (msgs: any) => msgs,
|
|
||||||
normalizeContentFromAPI: (blocks: any[]) => blocks,
|
|
||||||
createAssistantAPIErrorMessage: (opts: any) => ({
|
|
||||||
type: 'assistant',
|
|
||||||
message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError },
|
|
||||||
uuid: 'error-uuid',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/api.js', () => ({
|
|
||||||
toolToAPISchema: async (t: any) => t,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../Tool.js', () => ({
|
|
||||||
getEmptyToolPermissionContext: () => ({
|
|
||||||
alwaysAllow: [],
|
|
||||||
alwaysDeny: [],
|
|
||||||
needsPermission: [],
|
|
||||||
mode: 'default',
|
|
||||||
isBypassingPermissions: false,
|
|
||||||
}),
|
|
||||||
toolMatchesName: () => false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/envUtils.js', () => ({
|
|
||||||
isEnvTruthy: (v: string | undefined) => v === '1' || v === 'true',
|
|
||||||
isEnvDefinedFalsy: (v: string | undefined) => v === '0' || v === 'false' || v === 'no' || v === 'off',
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/toolSearch.js', () => ({
|
|
||||||
isToolSearchEnabled: async () => false,
|
|
||||||
extractDiscoveredToolNames: () => new Set(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
|
|
||||||
isDeferredTool: () => false,
|
|
||||||
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../cost-tracker.js', () => ({
|
|
||||||
addToTotalSessionCost: () => {},
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/modelCost.js', () => ({
|
|
||||||
calculateUSDCost: () => 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../../utils/debug.js', () => ({
|
|
||||||
logForDebugging: () => {},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — stop_reason propagation', () => {
|
|
||||||
test('assembled AssistantMessage has stop_reason end_turn (not null)', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'Hello'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 10),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('assembled AssistantMessage has stop_reason tool_use', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'tool_use'),
|
|
||||||
makeInputJsonDelta(0, '{"cmd":"ls"}'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('tool_use', 20),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBe('tool_use')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('assembled AssistantMessage has stop_reason max_tokens', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'truncated'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('max_tokens', 8192),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Two assistant-typed items: the content message + the max_output_tokens error signal.
|
|
||||||
// The error signal is emitted as a synthetic assistant message by createAssistantAPIErrorMessage.
|
|
||||||
expect(assistantMessages).toHaveLength(2)
|
|
||||||
const contentMsg = assistantMessages[0]!
|
|
||||||
expect(contentMsg.message.stop_reason).toBe('max_tokens')
|
|
||||||
// Second item is the error signal (has apiError set)
|
|
||||||
const errorMsg = assistantMessages[1]!.message as any
|
|
||||||
expect(errorMsg.apiError).toBe('max_output_tokens')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('stop_reason is null when no message_delta was received (safety fallback path)', async () => {
|
|
||||||
// Stream ends without message_stop — triggers the safety fallback branch.
|
|
||||||
// stop_reason stays null since no message_delta was ever seen.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'partial'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
// No message_delta / message_stop
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Safety fallback should yield the partial content
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
expect(assistantMessages[0]!.message.stop_reason).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — usage accumulation', () => {
|
|
||||||
test('usage in assembled message reflects all four fields from message_delta', async () => {
|
|
||||||
// message_start has all fields=0 (trailing-chunk pattern: usage not yet available).
|
|
||||||
// message_delta carries the real values after stream ends.
|
|
||||||
// The spread in the message_delta handler must override all zeros from message_start,
|
|
||||||
// including cache_read_input_tokens which was previously missing from message_delta.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'response'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
// message_delta carries all four Anthropic usage fields (as emitted by the fixed streamAdapter)
|
|
||||||
{
|
|
||||||
type: 'message_delta',
|
|
||||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
|
||||||
usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 },
|
|
||||||
} as any,
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
const usage = assistantMessages[0]!.message.usage as any
|
|
||||||
expect(usage.input_tokens).toBe(30011)
|
|
||||||
expect(usage.output_tokens).toBe(190)
|
|
||||||
// cache_read_input_tokens from message_delta overrides the 0 from message_start
|
|
||||||
expect(usage.cache_read_input_tokens).toBe(19904)
|
|
||||||
expect(usage.cache_creation_input_tokens).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('usage is zero when no usage events arrive (prevents false autocompact)', async () => {
|
|
||||||
// If usage stays 0, tokenCountWithEstimation will undercount — so at least
|
|
||||||
// verify the field exists and is numeric (to detect regressions).
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 0),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
const usage = assistantMessages[0]!.message.usage as any
|
|
||||||
expect(typeof usage.input_tokens).toBe('number')
|
|
||||||
expect(typeof usage.output_tokens).toBe('number')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — no duplicate AssistantMessage (partialMessage reset)', () => {
|
|
||||||
test('yields exactly one AssistantMessage per message_stop when content is present', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'only once'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
// Before the fix, partialMessage was not reset to null, so the safety
|
|
||||||
// fallback at the end of the loop would yield a second message with the
|
|
||||||
// same message.id — causing mergeAssistantMessages to concatenate content.
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('thinking + text response yields exactly one AssistantMessage', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'thinking'),
|
|
||||||
makeThinkingDelta(0, 'let me think'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeContentBlockStart(1, 'text'),
|
|
||||||
makeTextDelta(1, 'answer'),
|
|
||||||
makeContentBlockStop(1),
|
|
||||||
makeMessageDelta('end_turn', 30),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('safety fallback path still yields message when stream ends without message_stop', async () => {
|
|
||||||
// Simulates a stream that cuts off without the normal termination sequence.
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'abrupt end'),
|
|
||||||
// No content_block_stop, no message_delta, no message_stop
|
|
||||||
]
|
|
||||||
|
|
||||||
const { assistantMessages } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(assistantMessages).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — stream_events forwarded', () => {
|
|
||||||
test('every adapted event is also yielded as stream_event for real-time display', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hello'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
const { streamEvents } = await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
const eventTypes = streamEvents.map(e => (e as any).event?.type)
|
|
||||||
expect(eventTypes).toContain('message_start')
|
|
||||||
expect(eventTypes).toContain('content_block_start')
|
|
||||||
expect(eventTypes).toContain('content_block_delta')
|
|
||||||
expect(eventTypes).toContain('content_block_stop')
|
|
||||||
expect(eventTypes).toContain('message_delta')
|
|
||||||
expect(eventTypes).toContain('message_stop')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('queryModelOpenAI — max_tokens forwarded to request', () => {
|
|
||||||
test('buildOpenAIRequestBody includes max_tokens in the request payload', async () => {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(_lastCreateArgs).not.toBeNull()
|
|
||||||
expect(_lastCreateArgs!.max_tokens).toBe(8192)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('OPENAI_MAX_TOKENS env var overrides max_tokens', async () => {
|
|
||||||
const original = process.env.OPENAI_MAX_TOKENS
|
|
||||||
process.env.OPENAI_MAX_TOKENS = '4096'
|
|
||||||
try {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(_lastCreateArgs).not.toBeNull()
|
|
||||||
expect(_lastCreateArgs!.max_tokens).toBe(4096)
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.OPENAI_MAX_TOKENS
|
|
||||||
} else {
|
|
||||||
process.env.OPENAI_MAX_TOKENS = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('CLAUDE_CODE_MAX_OUTPUT_TOKENS env var overrides max_tokens', async () => {
|
|
||||||
const original = process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
|
||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '2048'
|
|
||||||
try {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(_lastCreateArgs).not.toBeNull()
|
|
||||||
expect(_lastCreateArgs!.max_tokens).toBe(2048)
|
|
||||||
} finally {
|
|
||||||
if (original === undefined) {
|
|
||||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
|
||||||
} else {
|
|
||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('OPENAI_MAX_TOKENS takes priority over CLAUDE_CODE_MAX_OUTPUT_TOKENS', async () => {
|
|
||||||
const origOpenai = process.env.OPENAI_MAX_TOKENS
|
|
||||||
const origClaude = process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
|
||||||
process.env.OPENAI_MAX_TOKENS = '4096'
|
|
||||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '2048'
|
|
||||||
try {
|
|
||||||
_nextEvents = [
|
|
||||||
makeMessageStart(),
|
|
||||||
makeContentBlockStart(0, 'text'),
|
|
||||||
makeTextDelta(0, 'hi'),
|
|
||||||
makeContentBlockStop(0),
|
|
||||||
makeMessageDelta('end_turn', 5),
|
|
||||||
makeMessageStop(),
|
|
||||||
]
|
|
||||||
|
|
||||||
await runQueryModel(_nextEvents)
|
|
||||||
|
|
||||||
expect(_lastCreateArgs).not.toBeNull()
|
|
||||||
expect(_lastCreateArgs!.max_tokens).toBe(4096)
|
|
||||||
} finally {
|
|
||||||
if (origOpenai === undefined) delete process.env.OPENAI_MAX_TOKENS
|
|
||||||
else process.env.OPENAI_MAX_TOKENS = origOpenai
|
|
||||||
if (origClaude === undefined) delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
|
||||||
else process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = origClaude
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||||
import { isOpenAIThinkingEnabled, buildOpenAIRequestBody } from '../index.js'
|
import { isOpenAIThinkingEnabled, buildOpenAIRequestBody } from '../requestBody.js'
|
||||||
|
|
||||||
describe('isOpenAIThinkingEnabled', () => {
|
describe('isOpenAIThinkingEnabled', () => {
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
|
|||||||
@@ -10,17 +10,10 @@ import type { AgentId } from '../../../types/ids.js'
|
|||||||
import type { Tools } from '../../../Tool.js'
|
import type { Tools } from '../../../Tool.js'
|
||||||
import type { Stream } from 'openai/streaming.mjs'
|
import type { Stream } from 'openai/streaming.mjs'
|
||||||
import type {
|
import type {
|
||||||
ChatCompletionChunk,
|
|
||||||
ChatCompletionCreateParamsStreaming,
|
ChatCompletionCreateParamsStreaming,
|
||||||
} from 'openai/resources/chat/completions/completions.mjs'
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
import { getOpenAIClient } from './client.js'
|
import { getOpenAIClient } from './client.js'
|
||||||
import { anthropicMessagesToOpenAI } from './convertMessages.js'
|
import { anthropicMessagesToOpenAI, resolveOpenAIModel, adaptOpenAIStreamToAnthropic, anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '@ant/model-provider'
|
||||||
import {
|
|
||||||
anthropicToolsToOpenAI,
|
|
||||||
anthropicToolChoiceToOpenAI,
|
|
||||||
} from './convertTools.js'
|
|
||||||
import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js'
|
|
||||||
import { resolveOpenAIModel } from './modelMapping.js'
|
|
||||||
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
||||||
import { toolToAPISchema } from '../../../utils/api.js'
|
import { toolToAPISchema } from '../../../utils/api.js'
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +23,8 @@ import {
|
|||||||
import { logForDebugging } from '../../../utils/debug.js'
|
import { logForDebugging } from '../../../utils/debug.js'
|
||||||
import { addToTotalSessionCost } from '../../../cost-tracker.js'
|
import { addToTotalSessionCost } from '../../../cost-tracker.js'
|
||||||
import { calculateUSDCost } from '../../../utils/modelCost.js'
|
import { calculateUSDCost } from '../../../utils/modelCost.js'
|
||||||
import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js'
|
import { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody } from './requestBody.js'
|
||||||
|
export { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody }
|
||||||
import { getModelMaxOutputTokens } from '../../../utils/context.js'
|
import { getModelMaxOutputTokens } from '../../../utils/context.js'
|
||||||
import type { Options } from '../claude.js'
|
import type { Options } from '../claude.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
@@ -48,104 +42,6 @@ import {
|
|||||||
TOOL_SEARCH_TOOL_NAME,
|
TOOL_SEARCH_TOOL_NAME,
|
||||||
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether DeepSeek-style thinking mode should be enabled.
|
|
||||||
*
|
|
||||||
* Enabled when:
|
|
||||||
* 1. OPENAI_ENABLE_THINKING=1 is set (explicit enable), OR
|
|
||||||
* 2. Model name contains "deepseek-reasoner" OR "DeepSeek-V3.2" (auto-detect, case-insensitive)
|
|
||||||
*
|
|
||||||
* Disabled when:
|
|
||||||
* - OPENAI_ENABLE_THINKING=0/false/no/off is explicitly set (overrides model detection)
|
|
||||||
*
|
|
||||||
* @param model - The resolved OpenAI model name
|
|
||||||
* @internal Exported for testing purposes only
|
|
||||||
*/
|
|
||||||
export function isOpenAIThinkingEnabled(model: string): boolean {
|
|
||||||
// Explicit disable takes priority (overrides model auto-detect)
|
|
||||||
if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false
|
|
||||||
// Explicit enable
|
|
||||||
if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true
|
|
||||||
// Auto-detect from model name (deepseek-reasoner and DeepSeek-V3.2 support thinking mode)
|
|
||||||
const modelLower = model.toLowerCase()
|
|
||||||
return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve max output tokens for the OpenAI-compatible path.
|
|
||||||
*
|
|
||||||
* Override priority:
|
|
||||||
* 1. maxOutputTokensOverride (programmatic, from query pipeline)
|
|
||||||
* 2. OPENAI_MAX_TOKENS env var (OpenAI-specific, useful for local models
|
|
||||||
* with small context windows, e.g. RTX 3060 12GB running 65536-token models)
|
|
||||||
* 3. CLAUDE_CODE_MAX_OUTPUT_TOKENS env var (generic override)
|
|
||||||
* 4. upperLimit default (64000)
|
|
||||||
*
|
|
||||||
* @internal Exported for testing purposes only
|
|
||||||
*/
|
|
||||||
export function resolveOpenAIMaxTokens(
|
|
||||||
upperLimit: number,
|
|
||||||
maxOutputTokensOverride?: number,
|
|
||||||
): number {
|
|
||||||
return maxOutputTokensOverride
|
|
||||||
?? (process.env.OPENAI_MAX_TOKENS ? parseInt(process.env.OPENAI_MAX_TOKENS, 10) || undefined : undefined)
|
|
||||||
?? (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS ? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined : undefined)
|
|
||||||
?? upperLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the request body for OpenAI chat.completions.create().
|
|
||||||
* Extracted for testability — the thinking mode params are injected here.
|
|
||||||
*
|
|
||||||
* DeepSeek thinking mode: inject thinking params via request body.
|
|
||||||
* Two formats are added simultaneously to support different deployments:
|
|
||||||
* - Official DeepSeek API: `thinking: { type: 'enabled' }`
|
|
||||||
* - Self-hosted DeepSeek-V3.2: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
|
|
||||||
* OpenAI SDK passes unknown keys through to the HTTP body.
|
|
||||||
* Each endpoint will use the format it recognizes and ignore the others.
|
|
||||||
* @internal Exported for testing purposes only
|
|
||||||
*/
|
|
||||||
export function buildOpenAIRequestBody(params: {
|
|
||||||
model: string
|
|
||||||
messages: any[]
|
|
||||||
tools: any[]
|
|
||||||
toolChoice: any
|
|
||||||
enableThinking: boolean
|
|
||||||
maxTokens: number
|
|
||||||
temperatureOverride?: number
|
|
||||||
}): ChatCompletionCreateParamsStreaming & {
|
|
||||||
thinking?: { type: string }
|
|
||||||
enable_thinking?: boolean
|
|
||||||
chat_template_kwargs?: { thinking: boolean }
|
|
||||||
} {
|
|
||||||
const { model, messages, tools, toolChoice, enableThinking, maxTokens, temperatureOverride } = params
|
|
||||||
return {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
max_tokens: maxTokens,
|
|
||||||
...(tools.length > 0 && {
|
|
||||||
tools,
|
|
||||||
...(toolChoice && { tool_choice: toolChoice }),
|
|
||||||
}),
|
|
||||||
stream: true,
|
|
||||||
stream_options: { include_usage: true },
|
|
||||||
// DeepSeek thinking mode: enable chain-of-thought output.
|
|
||||||
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored by DeepSeek.
|
|
||||||
...(enableThinking && {
|
|
||||||
// Official DeepSeek API format
|
|
||||||
thinking: { type: 'enabled' },
|
|
||||||
// Self-hosted DeepSeek-V3.2 format
|
|
||||||
enable_thinking: true,
|
|
||||||
chat_template_kwargs: { thinking: true },
|
|
||||||
}),
|
|
||||||
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
|
|
||||||
// but other providers may respect it)
|
|
||||||
...(!enableThinking && temperatureOverride !== undefined && {
|
|
||||||
temperature: temperatureOverride,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assemble the final AssistantMessage (and optional max_tokens error) from
|
* Assemble the final AssistantMessage (and optional max_tokens error) from
|
||||||
* accumulated stream state. Extracted to avoid duplication between the
|
* accumulated stream state. Extracted to avoid duplication between the
|
||||||
|
|||||||
103
src/services/api/openai/requestBody.ts
Normal file
103
src/services/api/openai/requestBody.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Pure utility functions for building OpenAI request bodies and detecting
|
||||||
|
* thinking mode. Extracted from index.ts so tests can import them without
|
||||||
|
* triggering heavy module side-effects (OpenAI client, stream adapter, etc.).
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
ChatCompletionCreateParamsStreaming,
|
||||||
|
} from 'openai/resources/chat/completions/completions.mjs'
|
||||||
|
import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether DeepSeek-style thinking mode should be enabled.
|
||||||
|
*
|
||||||
|
* Enabled when:
|
||||||
|
* 1. OPENAI_ENABLE_THINKING=1 is set (explicit enable), OR
|
||||||
|
* 2. Model name contains "deepseek-reasoner" OR "DeepSeek-V3.2" (auto-detect, case-insensitive)
|
||||||
|
*
|
||||||
|
* Disabled when:
|
||||||
|
* - OPENAI_ENABLE_THINKING=0/false/no/off is explicitly set (overrides model detection)
|
||||||
|
*
|
||||||
|
* @param model - The resolved OpenAI model name
|
||||||
|
*/
|
||||||
|
export function isOpenAIThinkingEnabled(model: string): boolean {
|
||||||
|
// Explicit disable takes priority (overrides model auto-detect)
|
||||||
|
if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false
|
||||||
|
// Explicit enable
|
||||||
|
if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true
|
||||||
|
// Auto-detect from model name (deepseek-reasoner and DeepSeek-V3.2 support thinking mode)
|
||||||
|
const modelLower = model.toLowerCase()
|
||||||
|
return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve max output tokens for the OpenAI-compatible path.
|
||||||
|
*
|
||||||
|
* Override priority:
|
||||||
|
* 1. maxOutputTokensOverride (programmatic, from query pipeline)
|
||||||
|
* 2. OPENAI_MAX_TOKENS env var (OpenAI-specific, useful for local models
|
||||||
|
* with small context windows, e.g. RTX 3060 12GB running 65536-token models)
|
||||||
|
* 3. CLAUDE_CODE_MAX_OUTPUT_TOKENS env var (generic override)
|
||||||
|
* 4. upperLimit default (64000)
|
||||||
|
*/
|
||||||
|
export function resolveOpenAIMaxTokens(
|
||||||
|
upperLimit: number,
|
||||||
|
maxOutputTokensOverride?: number,
|
||||||
|
): number {
|
||||||
|
return maxOutputTokensOverride
|
||||||
|
?? (process.env.OPENAI_MAX_TOKENS ? parseInt(process.env.OPENAI_MAX_TOKENS, 10) || undefined : undefined)
|
||||||
|
?? (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS ? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined : undefined)
|
||||||
|
?? upperLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the request body for OpenAI chat.completions.create().
|
||||||
|
* Extracted for testability — the thinking mode params are injected here.
|
||||||
|
*
|
||||||
|
* DeepSeek thinking mode: inject thinking params via request body.
|
||||||
|
* Two formats are added simultaneously to support different deployments:
|
||||||
|
* - Official DeepSeek API: `thinking: { type: 'enabled' }`
|
||||||
|
* - Self-hosted DeepSeek-V3.2: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }`
|
||||||
|
* OpenAI SDK passes unknown keys through to the HTTP body.
|
||||||
|
* Each endpoint will use the format it recognizes and ignore the others.
|
||||||
|
*/
|
||||||
|
export function buildOpenAIRequestBody(params: {
|
||||||
|
model: string
|
||||||
|
messages: any[]
|
||||||
|
tools: any[]
|
||||||
|
toolChoice: any
|
||||||
|
enableThinking: boolean
|
||||||
|
maxTokens: number
|
||||||
|
temperatureOverride?: number
|
||||||
|
}): ChatCompletionCreateParamsStreaming & {
|
||||||
|
thinking?: { type: string }
|
||||||
|
enable_thinking?: boolean
|
||||||
|
chat_template_kwargs?: { thinking: boolean }
|
||||||
|
} {
|
||||||
|
const { model, messages, tools, toolChoice, enableThinking, maxTokens, temperatureOverride } = params
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
...(tools.length > 0 && {
|
||||||
|
tools,
|
||||||
|
...(toolChoice && { tool_choice: toolChoice }),
|
||||||
|
}),
|
||||||
|
stream: true,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
// DeepSeek thinking mode: enable chain-of-thought output.
|
||||||
|
// When active, temperature/top_p/presence_penalty/frequency_penalty are ignored by DeepSeek.
|
||||||
|
...(enableThinking && {
|
||||||
|
// Official DeepSeek API format
|
||||||
|
thinking: { type: 'enabled' },
|
||||||
|
// Self-hosted DeepSeek-V3.2 format
|
||||||
|
enable_thinking: true,
|
||||||
|
chat_template_kwargs: { thinking: true },
|
||||||
|
}),
|
||||||
|
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
|
||||||
|
// but other providers may respect it)
|
||||||
|
...(!enableThinking && temperatureOverride !== undefined && {
|
||||||
|
temperature: temperatureOverride,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,141 +1,74 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// Re-export core message types from @ant/model-provider
|
||||||
import type { UUID } from 'crypto'
|
// This file adds UI-specific types on top of the base types.
|
||||||
import type {
|
export type {
|
||||||
ContentBlockParam,
|
MessageType,
|
||||||
ContentBlock,
|
ContentItem,
|
||||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
MessageContent,
|
||||||
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
TypedMessageContent,
|
||||||
|
Message,
|
||||||
|
AssistantMessage,
|
||||||
|
AttachmentMessage,
|
||||||
|
ProgressMessage,
|
||||||
|
SystemLocalCommandMessage,
|
||||||
|
SystemMessage,
|
||||||
|
UserMessage,
|
||||||
|
NormalizedUserMessage,
|
||||||
|
RequestStartEvent,
|
||||||
|
StreamEvent,
|
||||||
|
SystemCompactBoundaryMessage,
|
||||||
|
TombstoneMessage,
|
||||||
|
ToolUseSummaryMessage,
|
||||||
|
MessageOrigin,
|
||||||
|
CompactMetadata,
|
||||||
|
SystemAPIErrorMessage,
|
||||||
|
SystemFileSnapshotMessage,
|
||||||
|
NormalizedAssistantMessage,
|
||||||
|
NormalizedMessage,
|
||||||
|
PartialCompactDirection,
|
||||||
|
StopHookInfo,
|
||||||
|
SystemAgentsKilledMessage,
|
||||||
|
SystemApiMetricsMessage,
|
||||||
|
SystemAwaySummaryMessage,
|
||||||
|
SystemBridgeStatusMessage,
|
||||||
|
SystemInformationalMessage,
|
||||||
|
SystemMemorySavedMessage,
|
||||||
|
SystemMessageLevel,
|
||||||
|
SystemMicrocompactBoundaryMessage,
|
||||||
|
SystemPermissionRetryMessage,
|
||||||
|
SystemScheduledTaskFireMessage,
|
||||||
|
SystemStopHookSummaryMessage,
|
||||||
|
SystemTurnDurationMessage,
|
||||||
|
GroupedToolUseMessage,
|
||||||
|
CollapsibleMessage,
|
||||||
|
HookResultMessage,
|
||||||
|
SystemThinkingMessage,
|
||||||
|
} from '@ant/model-provider'
|
||||||
|
|
||||||
|
// UI-specific types that depend on main-project internals
|
||||||
import type {
|
import type {
|
||||||
BranchAction,
|
BranchAction,
|
||||||
CommitKind,
|
CommitKind,
|
||||||
PrAction,
|
PrAction,
|
||||||
} from '@claude-code-best/builtin-tools/tools/shared/gitOperationTracking.js'
|
} from '@claude-code-best/builtin-tools/tools/shared/gitOperationTracking.js'
|
||||||
|
import type {
|
||||||
/**
|
AssistantMessage,
|
||||||
* Base message type with discriminant `type` field and common properties.
|
CollapsibleMessage,
|
||||||
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
|
NormalizedAssistantMessage,
|
||||||
* this with narrower `type` literals and additional fields.
|
NormalizedUserMessage,
|
||||||
*/
|
UserMessage,
|
||||||
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
|
} from '@ant/model-provider'
|
||||||
|
import type { UUID } from 'crypto'
|
||||||
/** A single content element inside message.content arrays. */
|
import type { StopHookInfo } from '@ant/model-provider'
|
||||||
export type ContentItem = ContentBlockParam | ContentBlock
|
|
||||||
|
|
||||||
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed content array — used in narrowed message subtypes so that
|
|
||||||
* `message.content[0]` resolves to `ContentItem` instead of
|
|
||||||
* `string | ContentBlockParam | ContentBlock`.
|
|
||||||
*/
|
|
||||||
export type TypedMessageContent = ContentItem[]
|
|
||||||
|
|
||||||
export type Message = {
|
|
||||||
type: MessageType
|
|
||||||
uuid: UUID
|
|
||||||
isMeta?: boolean
|
|
||||||
isCompactSummary?: boolean
|
|
||||||
toolUseResult?: unknown
|
|
||||||
isVisibleInTranscriptOnly?: boolean
|
|
||||||
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
|
|
||||||
message?: {
|
|
||||||
role?: string
|
|
||||||
id?: string
|
|
||||||
content?: MessageContent
|
|
||||||
usage?: BetaUsage | Record<string, unknown>
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AssistantMessage = Message & {
|
|
||||||
type: 'assistant'
|
|
||||||
message: NonNullable<Message['message']>
|
|
||||||
}
|
|
||||||
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
|
|
||||||
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
|
|
||||||
export type SystemLocalCommandMessage = Message & { type: 'system' }
|
|
||||||
export type SystemMessage = Message & { type: 'system' }
|
|
||||||
export type UserMessage = Message & {
|
|
||||||
type: 'user'
|
|
||||||
message: NonNullable<Message['message']>
|
|
||||||
imagePasteIds?: number[]
|
|
||||||
}
|
|
||||||
export type NormalizedUserMessage = UserMessage
|
|
||||||
export type RequestStartEvent = { type: string; [key: string]: unknown }
|
|
||||||
export type StreamEvent = { type: string; [key: string]: unknown }
|
|
||||||
export type SystemCompactBoundaryMessage = Message & {
|
|
||||||
type: 'system'
|
|
||||||
compactMetadata: {
|
|
||||||
preservedSegment?: {
|
|
||||||
headUuid: UUID
|
|
||||||
tailUuid: UUID
|
|
||||||
anchorUuid: UUID
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export type TombstoneMessage = Message
|
|
||||||
export type ToolUseSummaryMessage = Message
|
|
||||||
export type MessageOrigin = string
|
|
||||||
export type CompactMetadata = Record<string, unknown>
|
|
||||||
export type SystemAPIErrorMessage = Message & { type: 'system' }
|
|
||||||
export type SystemFileSnapshotMessage = Message & { type: 'system' }
|
|
||||||
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
|
|
||||||
export type NormalizedMessage = Message
|
|
||||||
export type PartialCompactDirection = string
|
|
||||||
|
|
||||||
export type StopHookInfo = {
|
|
||||||
command?: string
|
|
||||||
durationMs?: number
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SystemAgentsKilledMessage = Message & { type: 'system' }
|
|
||||||
export type SystemApiMetricsMessage = Message & { type: 'system' }
|
|
||||||
export type SystemAwaySummaryMessage = Message & { type: 'system' }
|
|
||||||
export type SystemBridgeStatusMessage = Message & { type: 'system' }
|
|
||||||
export type SystemInformationalMessage = Message & { type: 'system' }
|
|
||||||
export type SystemMemorySavedMessage = Message & { type: 'system' }
|
|
||||||
export type SystemMessageLevel = string
|
|
||||||
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
|
|
||||||
export type SystemPermissionRetryMessage = Message & { type: 'system' }
|
|
||||||
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
|
|
||||||
|
|
||||||
export type SystemStopHookSummaryMessage = Message & {
|
|
||||||
type: 'system'
|
|
||||||
subtype: string
|
|
||||||
hookLabel: string
|
|
||||||
hookCount: number
|
|
||||||
totalDurationMs?: number
|
|
||||||
hookInfos: StopHookInfo[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SystemTurnDurationMessage = Message & { type: 'system' }
|
|
||||||
|
|
||||||
export type GroupedToolUseMessage = Message & {
|
|
||||||
type: 'grouped_tool_use'
|
|
||||||
toolName: string
|
|
||||||
messages: NormalizedAssistantMessage[]
|
|
||||||
results: NormalizedUserMessage[]
|
|
||||||
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RenderableMessage =
|
export type RenderableMessage =
|
||||||
| AssistantMessage
|
| AssistantMessage
|
||||||
| UserMessage
|
| UserMessage
|
||||||
| (Message & { type: 'system' })
|
| (import('@ant/model-provider').Message & { type: 'system' })
|
||||||
| (Message & { type: 'attachment'; attachment: { type: string; memories?: { path: string; content: string; mtimeMs: number }[]; [key: string]: unknown } })
|
| (import('@ant/model-provider').Message & { type: 'attachment'; attachment: { type: string; memories?: { path: string; content: string; mtimeMs: number }[]; [key: string]: unknown } })
|
||||||
| (Message & { type: 'progress' })
|
| (import('@ant/model-provider').Message & { type: 'progress' })
|
||||||
| GroupedToolUseMessage
|
| import('@ant/model-provider').GroupedToolUseMessage
|
||||||
| CollapsedReadSearchGroup
|
| CollapsedReadSearchGroup
|
||||||
|
|
||||||
export type CollapsibleMessage =
|
|
||||||
| AssistantMessage
|
|
||||||
| UserMessage
|
|
||||||
| GroupedToolUseMessage
|
|
||||||
|
|
||||||
export type CollapsedReadSearchGroup = {
|
export type CollapsedReadSearchGroup = {
|
||||||
type: 'collapsed_read_search'
|
type: 'collapsed_read_search'
|
||||||
uuid: UUID
|
uuid: UUID
|
||||||
@@ -169,6 +102,3 @@ export type CollapsedReadSearchGroup = {
|
|||||||
teamMemoryWriteCount?: number
|
teamMemoryWriteCount?: number
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HookResultMessage = Message
|
|
||||||
export type SystemThinkingMessage = Message & { type: 'system' }
|
|
||||||
|
|||||||
74
src/utils/__tests__/bunHashPolyfill.test.ts
Normal file
74
src/utils/__tests__/bunHashPolyfill.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Tests for fix: 修复 Bun.hash 不存在的问题 (ecbd5a9)
|
||||||
|
*
|
||||||
|
* The Node.js polyfill in build.ts injects a FNV-1a hash implementation as
|
||||||
|
* globalThis.Bun.hash so bundled output doesn't crash under plain Node.js.
|
||||||
|
* We test the algorithm directly here to guard against regressions.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline copy of the polyfill from build.ts — keep in sync if the
|
||||||
|
* implementation changes.
|
||||||
|
*/
|
||||||
|
function bunHashPolyfill(data: string, seed?: number): number {
|
||||||
|
let h = ((seed || 0) ^ 0x811c9dc5) >>> 0
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
h ^= data.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Bun.hash Node.js polyfill (FNV-1a)', () => {
|
||||||
|
test('returns a number', () => {
|
||||||
|
expect(typeof bunHashPolyfill('hello')).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns a 32-bit unsigned integer', () => {
|
||||||
|
const h = bunHashPolyfill('test')
|
||||||
|
expect(h).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(h).toBeLessThanOrEqual(0xffffffff)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is deterministic', () => {
|
||||||
|
expect(bunHashPolyfill('hello')).toBe(bunHashPolyfill('hello'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('different inputs produce different hashes', () => {
|
||||||
|
expect(bunHashPolyfill('abc')).not.toBe(bunHashPolyfill('def'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty string returns seed-derived value (no crash)', () => {
|
||||||
|
const h = bunHashPolyfill('')
|
||||||
|
expect(typeof h).toBe('number')
|
||||||
|
expect(h).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('seed=0 and no seed produce the same result', () => {
|
||||||
|
expect(bunHashPolyfill('hello', 0)).toBe(bunHashPolyfill('hello'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('different seeds produce different hashes for same input', () => {
|
||||||
|
expect(bunHashPolyfill('hello', 1)).not.toBe(bunHashPolyfill('hello', 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('result is always an unsigned 32-bit integer (no negative values)', () => {
|
||||||
|
const inputs = ['', 'a', 'hello world', '\x00\xff', 'unicode: 你好']
|
||||||
|
for (const input of inputs) {
|
||||||
|
const h = bunHashPolyfill(input)
|
||||||
|
expect(h).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(Number.isInteger(h)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Bun.hash native returns a numeric type (bigint or number)', () => {
|
||||||
|
// Bun.hash returns a bigint (64-bit), while the polyfill returns a 32-bit
|
||||||
|
// unsigned int. They use different widths so direct equality is not expected.
|
||||||
|
// This test just verifies the native API exists and returns a numeric type.
|
||||||
|
if (typeof globalThis.Bun?.hash === 'function') {
|
||||||
|
const result = (globalThis.Bun.hash as (s: string) => bigint | number)('hello')
|
||||||
|
expect(['number', 'bigint']).toContain(typeof result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
104
src/utils/__tests__/earlyInput.test.ts
Normal file
104
src/utils/__tests__/earlyInput.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Tests for fix: prevent iTerm2 terminal response sequences from leaking into REPL input (#172)
|
||||||
|
*
|
||||||
|
* The earlyInput processChunk() was too simplistic — it only checked if the
|
||||||
|
* byte after ESC fell in 0x40-0x7E, causing DCS/CSI sequences to partially
|
||||||
|
* leak into the buffer. The fix handles each escape sequence type per ECMA-48.
|
||||||
|
*
|
||||||
|
* processChunk() is private, so we test via the stdin data path by directly
|
||||||
|
* manipulating the module-level buffer through seedEarlyInput / consumeEarlyInput,
|
||||||
|
* and by verifying the public API behaviour with known-bad inputs.
|
||||||
|
*
|
||||||
|
* For the escape-sequence filtering we export a thin test helper that calls
|
||||||
|
* processChunk indirectly via a fake stdin emit — but since that requires a
|
||||||
|
* real TTY, we instead test the observable contract: after startup, sequences
|
||||||
|
* that previously leaked must not appear in consumeEarlyInput().
|
||||||
|
*
|
||||||
|
* NOTE: processChunk is not exported, so these tests cover the public surface
|
||||||
|
* (seedEarlyInput / consumeEarlyInput / hasEarlyInput) and document the
|
||||||
|
* regression scenarios as integration-style assertions.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test, beforeEach } from 'bun:test'
|
||||||
|
import {
|
||||||
|
seedEarlyInput,
|
||||||
|
consumeEarlyInput,
|
||||||
|
hasEarlyInput,
|
||||||
|
} from '../earlyInput.js'
|
||||||
|
|
||||||
|
// Reset buffer state before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
consumeEarlyInput() // drains buffer
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('earlyInput public API', () => {
|
||||||
|
test('seedEarlyInput sets the buffer', () => {
|
||||||
|
seedEarlyInput('hello')
|
||||||
|
expect(hasEarlyInput()).toBe(true)
|
||||||
|
expect(consumeEarlyInput()).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('consumeEarlyInput drains the buffer', () => {
|
||||||
|
seedEarlyInput('test')
|
||||||
|
consumeEarlyInput()
|
||||||
|
expect(hasEarlyInput()).toBe(false)
|
||||||
|
expect(consumeEarlyInput()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hasEarlyInput returns false for empty / whitespace-only buffer', () => {
|
||||||
|
seedEarlyInput(' ')
|
||||||
|
expect(hasEarlyInput()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('consumeEarlyInput trims whitespace', () => {
|
||||||
|
seedEarlyInput(' hello ')
|
||||||
|
expect(consumeEarlyInput()).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple seeds overwrite previous value', () => {
|
||||||
|
seedEarlyInput('first')
|
||||||
|
seedEarlyInput('second')
|
||||||
|
expect(consumeEarlyInput()).toBe('second')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('earlyInput escape sequence regression (fix: iTerm2 sequences leaking)', () => {
|
||||||
|
/**
|
||||||
|
* These tests document the sequences that previously leaked into the buffer.
|
||||||
|
* Since processChunk() is private, we verify the contract by seeding the
|
||||||
|
* buffer with already-clean text and confirming the API works correctly.
|
||||||
|
* The actual filtering is exercised by the integration path (stdin → processChunk).
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('DA1 response sequence pattern is documented (CSI ? ... c)', () => {
|
||||||
|
// \x1b[?64;1;2;4;6;17;18;21;22c — previously leaked as "?64;1;2;4;6;17;18;21;22c"
|
||||||
|
// After fix: CSI sequences are fully consumed, nothing leaks
|
||||||
|
// We document the expected clean output here
|
||||||
|
const leakedBefore = '?64;1;2;4;6;17;18;21;22c'
|
||||||
|
const cleanAfter = ''
|
||||||
|
// The fix ensures processChunk produces cleanAfter, not leakedBefore
|
||||||
|
// (verified manually; this test documents the contract)
|
||||||
|
expect(leakedBefore).not.toBe(cleanAfter) // sanity: they differ
|
||||||
|
expect(cleanAfter).toBe('') // after fix: nothing leaks
|
||||||
|
})
|
||||||
|
|
||||||
|
test('XTVERSION DCS sequence pattern is documented (ESC P ... ESC \\)', () => {
|
||||||
|
// \x1bP>|iTerm2 3.6.4\x1b\\ — previously leaked as ">|iTerm2 3.6.4"
|
||||||
|
// After fix: DCS sequences are fully consumed via ST terminator
|
||||||
|
const leakedBefore = '>|iTerm2 3.6.4'
|
||||||
|
const cleanAfter = ''
|
||||||
|
expect(leakedBefore).not.toBe(cleanAfter)
|
||||||
|
expect(cleanAfter).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('normal text after escape sequence is preserved', () => {
|
||||||
|
// Seed with clean text (simulating what processChunk would produce after filtering)
|
||||||
|
seedEarlyInput('hello world')
|
||||||
|
expect(consumeEarlyInput()).toBe('hello world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty result when only escape sequences present', () => {
|
||||||
|
// After filtering, buffer should be empty
|
||||||
|
seedEarlyInput('')
|
||||||
|
expect(consumeEarlyInput()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
93
src/utils/__tests__/imageResizer.test.ts
Normal file
93
src/utils/__tests__/imageResizer.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Tests for fix: 修复截图 MIME 类型硬编码导致 API 拒绝的问题
|
||||||
|
*
|
||||||
|
* macOS screencapture outputs PNG but the code was hardcoding "image/jpeg",
|
||||||
|
* causing API errors. The fix detects the actual format from magic bytes.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { detectImageFormatFromBase64, detectImageFormatFromBuffer } from '../imageResizer.js'
|
||||||
|
|
||||||
|
// ── Magic byte helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** PNG magic bytes: 0x89 0x50 0x4E 0x47 ... */
|
||||||
|
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
/** JPEG magic bytes: 0xFF 0xD8 0xFF */
|
||||||
|
const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
|
||||||
|
/** GIF magic bytes: GIF89a */
|
||||||
|
const GIF_HEADER = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||||
|
/** WebP: RIFF....WEBP */
|
||||||
|
const WEBP_HEADER = Buffer.from([
|
||||||
|
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||||
|
0x00, 0x00, 0x00, 0x00, // file size (placeholder)
|
||||||
|
0x57, 0x45, 0x42, 0x50, // WEBP
|
||||||
|
])
|
||||||
|
|
||||||
|
function toBase64(buf: Buffer): string {
|
||||||
|
return buf.toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── detectImageFormatFromBuffer ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('detectImageFormatFromBuffer', () => {
|
||||||
|
test('detects PNG from magic bytes', () => {
|
||||||
|
expect(detectImageFormatFromBuffer(PNG_HEADER)).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects JPEG from magic bytes', () => {
|
||||||
|
expect(detectImageFormatFromBuffer(JPEG_HEADER)).toBe('image/jpeg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects GIF from magic bytes', () => {
|
||||||
|
expect(detectImageFormatFromBuffer(GIF_HEADER)).toBe('image/gif')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects WebP from RIFF+WEBP magic bytes', () => {
|
||||||
|
expect(detectImageFormatFromBuffer(WEBP_HEADER)).toBe('image/webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns image/png as default for unknown format', () => {
|
||||||
|
const unknown = Buffer.from([0x00, 0x01, 0x02, 0x03])
|
||||||
|
expect(detectImageFormatFromBuffer(unknown)).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns image/png for buffer shorter than 4 bytes', () => {
|
||||||
|
expect(detectImageFormatFromBuffer(Buffer.from([0x89]))).toBe('image/png')
|
||||||
|
expect(detectImageFormatFromBuffer(Buffer.alloc(0))).toBe('image/png')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── detectImageFormatFromBase64 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('detectImageFormatFromBase64', () => {
|
||||||
|
test('detects PNG from base64-encoded PNG header', () => {
|
||||||
|
expect(detectImageFormatFromBase64(toBase64(PNG_HEADER))).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects JPEG from base64-encoded JPEG header', () => {
|
||||||
|
expect(detectImageFormatFromBase64(toBase64(JPEG_HEADER))).toBe('image/jpeg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects GIF from base64-encoded GIF header', () => {
|
||||||
|
expect(detectImageFormatFromBase64(toBase64(GIF_HEADER))).toBe('image/gif')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects WebP from base64-encoded WebP header', () => {
|
||||||
|
expect(detectImageFormatFromBase64(toBase64(WEBP_HEADER))).toBe('image/webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns image/png as default for empty string', () => {
|
||||||
|
expect(detectImageFormatFromBase64('')).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns image/png for invalid base64', () => {
|
||||||
|
// Should not throw — gracefully defaults
|
||||||
|
expect(detectImageFormatFromBase64('!!!not-base64!!!')).toBe('image/png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('macOS screencapture PNG is not misidentified as JPEG', () => {
|
||||||
|
// This is the core regression: PNG data must NOT return image/jpeg
|
||||||
|
const result = detectImageFormatFromBase64(toBase64(PNG_HEADER))
|
||||||
|
expect(result).not.toBe('image/jpeg')
|
||||||
|
expect(result).toBe('image/png')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
logEvent,
|
logEvent,
|
||||||
} from '../services/analytics/index.js'
|
} from '../services/analytics/index.js'
|
||||||
import { accumulateUsage, updateUsage } from '../services/api/claude.js'
|
import { accumulateUsage, updateUsage } from '../services/api/claude.js'
|
||||||
import { EMPTY_USAGE, type NonNullableUsage } from '../services/api/logging.js'
|
import { EMPTY_USAGE, type NonNullableUsage } from '@ant/model-provider'
|
||||||
import type { ToolUseContext } from '../Tool.js'
|
import type { ToolUseContext } from '../Tool.js'
|
||||||
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||||
import type { AgentId } from '../types/ids.js'
|
import type { AgentId } from '../types/ids.js'
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* while keeping the side question response separate from main conversation.
|
* while keeping the side question response separate from main conversation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatAPIError } from '../services/api/errorUtils.js'
|
import { formatAPIError } from '@ant/model-provider'
|
||||||
import type { NonNullableUsage } from '../services/api/logging.js'
|
import type { NonNullableUsage } from '@ant/model-provider'
|
||||||
import type { Message, SystemAPIErrorMessage } from '../types/message.js'
|
import type { Message, SystemAPIErrorMessage } from '../types/message.js'
|
||||||
import { type CacheSafeParams, runForkedAgent } from './forkedAgent.js'
|
import { type CacheSafeParams, runForkedAgent } from './forkedAgent.js'
|
||||||
import { createUserMessage, extractTextContent } from './messages.js'
|
import { createUserMessage, extractTextContent } from './messages.js'
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
/**
|
// Re-export SystemPrompt from @ant/model-provider
|
||||||
* Branded type for system prompt arrays.
|
// Kept here for backward compatibility.
|
||||||
*
|
export type { SystemPrompt } from '@ant/model-provider'
|
||||||
* This module is intentionally dependency-free so it can be imported
|
export { asSystemPrompt } from '@ant/model-provider'
|
||||||
* from anywhere without risking circular initialization issues.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SystemPrompt = readonly string[] & {
|
|
||||||
readonly __brand: 'SystemPrompt'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
|
||||||
return value as SystemPrompt
|
|
||||||
}
|
|
||||||
|
|||||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["bun", "@types/node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["bun", "@types/node"],
|
"types": ["bun"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"src/*": ["./src/*"],
|
"src/*": ["./src/*"],
|
||||||
"@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"],
|
"@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"],
|
||||||
|
|||||||
Reference in New Issue
Block a user