Add Droid (Factory.ai) provider support

This commit adds support for Factory.ai's Droid CLI as a provider,
enabling users to use Droid through an OpenAI-compatible API interface.

Implementation details:
- Created DroidApiService that wraps the droid CLI
- Added DroidStrategy for protocol conversion (Claude-compatible)
- Supports both streaming and non-streaming responses
- No API keys or token management required - uses droid CLI directly

Files added:
- src/droid/droid-core.js: Core service using CLI wrapper
- src/droid/droid-strategy.js: Strategy pattern implementation
- src/droid/README.md: Comprehensive documentation
- test-droid.js: Test script for validation

Files modified:
- src/adapter.js: Added DroidApiServiceAdapter
- src/common.js: Added DROID constants
- src/provider-strategies.js: Registered DroidStrategy
- README.md: Updated with Droid provider information
This commit is contained in:
bee4come 2025-10-15 11:29:17 +08:00
parent 262f119213
commit 3111119f93
8 changed files with 657 additions and 2 deletions

View file

@ -34,7 +34,7 @@
## 💡 核心优势
* ✅ **多模型统一接入**:通过统一的 OpenAI 兼容接口,轻松接入 Gemini、OpenAI、Claude、Kimi K2、GLM-4.5、Qwen Code 等多种主流大模型,并通过启动参数或请求头自由切换。
* ✅ **多模型统一接入**:通过统一的 OpenAI 兼容接口,轻松接入 Gemini、OpenAI、Claude、Factory Droid、Kimi K2、GLM-4.5、Qwen Code 等多种主流大模型,并通过启动参数或请求头自由切换。
* ✅ **突破官方限制**:利用 Gemini CLI 的 OAuth 授权,有效规避官方免费 API 的速率和配额限制,提升请求额度和使用频率。
* ✅ **免费使用 Claude Sonnet 4.5**:在 Kiro API 模式下,支持免费使用 Claude Sonnet 4.5 模型。
* ✅ **无缝兼容 OpenAI**:提供与 OpenAI API 完全兼容的接口,使 LobeChat, NextChat 等现有工具链和客户端能零成本接入所有支持模型。
@ -119,6 +119,11 @@
* **Qwen Code 支持**
* **授权流程**:首次使用 Qwen Code 时,会自动在浏览器中打开授权页面。完成授权后,`oauth_creds.json` 文件将生成并存储在 `~/.qwen` 目录下。
* **模型参数**:请使用官方默认参数 `temperature=0``top_p=1`
* **Droid (Factory.ai) 支持**
* **使用前提**:使用 Droid 需要[安装 Factory CLI](https://factory.ai/product/cli) 并完成认证登录,以生成 `~/.factory/auth.json` 文件。
* **认证流程**:运行 `droid` 命令并按提示登录OAuth tokens 将自动保存。
* **优势**:无需单独的 API key直接使用 Factory.ai 账号的 Claude 访问权限。
* **详细文档**:查看 [Droid Provider README](src/droid/README.md) 了解完整配置说明。
* **Kiro API**
* **使用前提**:使用 Kiro API 需要[下载 Kiro 客户端](https://aibook.ren/archives/kiro-install)并完成授权登录,以生成 `kiro-auth-token.json` 文件。
* **最佳体验**:推荐配合 Claude Code 使用以获得最佳体验。
@ -132,6 +137,7 @@
* `http://localhost:3000/openai-custom` - 使用 OpenAI 自定义供应商处理 Claude 请求。
* `http://localhost:3000/gemini-cli-oauth` - 使用 Gemini CLI OAuth 供应商处理 Claude 请求。
* `http://localhost:3000/openai-qwen-oauth` - 使用 Qwen OAuth 供应商处理 Claude 请求。
* `http://localhost:3000/droid-factory-oauth` - 使用 Factory.ai Droid OAuth 供应商访问 Claude API。
这些 Path 路由不仅适用于直接 API 调用,也可在 Cline、Kilo 等编程 Agent 中配置 API 端点时使用,实现灵活的模型调用。例如,将 Agent 的 API 端点设置为 `http://localhost:3000/claude-kiro-oauth` 即可调用通过 Kiro OAuth 认证的 Claude 模型。
@ -173,6 +179,7 @@
* **Gemini**: `~/.gemini/oauth_creds.json`
* **Kiro**: `~/.aws/sso/cache/kiro-auth-token.json`
* **Qwen**: `~/.qwen/oauth_creds.json`
* **Droid (Factory.ai)**: `~/.factory/auth.json`
其中 `~` 代表用户主目录。如果需要自定义路径,可以通过配置文件或环境变量进行设置。
@ -256,7 +263,7 @@ $env:HTTP_PROXY="http://your_proxy_address:port"
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商可选值openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth |
| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商可选值openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, droid-factory-oauth |
### 🧠 OpenAI 兼容提供商参数
@ -293,6 +300,13 @@ $env:HTTP_PROXY="http://your_proxy_address:port"
|------|------|--------|------|
| `--qwen-oauth-creds-file` | string | null | Qwen OAuth 凭据 JSON 文件路径 (当 `model-provider``openai-qwen-oauth` 时必需) |
### 🤖 Droid (Factory.ai) OAuth 认证参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--droid-auth-file` | string | ~/.factory/auth.json | Factory Droid OAuth 凭据文件路径 (当 `model-provider``droid-factory-oauth` 时可选) |
| `--droid-base-url` | string | https://api.anthropic.com | Droid 使用的 Claude API 基础 URL (可选) |
### 📝 系统提示配置参数
| 参数 | 类型 | 默认值 | 说明 |
@ -348,6 +362,9 @@ node src/api-server.js --model-provider gemini-cli-oauth --gemini-oauth-creds-ba
# 使用Gemini提供商凭据文件
node src/api-server.js --model-provider gemini-cli-oauth --gemini-oauth-creds-file /path/to/credentials.json --project-id your-project-id
# 使用Droid (Factory.ai) 提供商
node src/api-server.js --model-provider droid-factory-oauth
# 配置系统提示
node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mode append

View file

@ -3,6 +3,7 @@ import { OpenAIApiService } from './openai/openai-core.js'; // 导入OpenAIApiSe
import { ClaudeApiService } from './claude/claude-core.js'; // 导入ClaudeApiService
import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiService
import { QwenApiService } from './openai/qwen-core.js'; // 导入QwenApiService
import { DroidApiService } from './droid/droid-core.js'; // 导入DroidApiService
import { MODEL_PROVIDER } from './common.js'; // 导入 MODEL_PROVIDER
// 定义AI服务适配器接口
@ -241,6 +242,46 @@ export class QwenApiServiceAdapter extends ApiServiceAdapter {
}
}
// Droid (Factory.ai) API 服务适配器
export class DroidApiServiceAdapter extends ApiServiceAdapter {
constructor(config) {
super();
this.droidApiService = new DroidApiService(config);
}
async generateContent(model, requestBody) {
if (!this.droidApiService.isInitialized) {
console.warn("droidApiService not initialized, attempting to re-initialize...");
await this.droidApiService.initialize();
}
return this.droidApiService.generateContent(model, requestBody);
}
async *generateContentStream(model, requestBody) {
if (!this.droidApiService.isInitialized) {
console.warn("droidApiService not initialized, attempting to re-initialize...");
await this.droidApiService.initialize();
}
yield* this.droidApiService.generateContentStream(model, requestBody);
}
async listModels() {
if (!this.droidApiService.isInitialized) {
console.warn("droidApiService not initialized, attempting to re-initialize...");
await this.droidApiService.initialize();
}
return this.droidApiService.listModels();
}
async refreshToken() {
if (this.droidApiService.isExpiryDateNear()) {
console.log(`[Droid] Token expiry near, refreshing...`);
return this.droidApiService.refreshAccessToken();
}
return Promise.resolve();
}
}
// 用于存储服务适配器单例的映射
export const serviceInstances = {};
@ -266,6 +307,9 @@ export function getServiceAdapter(config) {
case MODEL_PROVIDER.QWEN_API:
serviceInstances[providerKey] = new QwenApiServiceAdapter(config);
break;
case MODEL_PROVIDER.DROID_API:
serviceInstances[providerKey] = new DroidApiServiceAdapter(config);
break;
default:
throw new Error(`Unsupported model provider: ${provider}`);
}

View file

@ -16,6 +16,7 @@ export const MODEL_PROTOCOL_PREFIX = {
GEMINI: 'gemini',
OPENAI: 'openai',
CLAUDE: 'claude',
DROID: 'droid', // Droid uses Claude protocol
}
export const MODEL_PROVIDER = {
@ -25,6 +26,7 @@ export const MODEL_PROVIDER = {
CLAUDE_CUSTOM: 'claude-custom',
KIRO_API: 'claude-kiro-oauth',
QWEN_API: 'openai-qwen-oauth',
DROID_API: 'droid-factory-oauth',
}
/**

227
src/droid/README.md Normal file
View file

@ -0,0 +1,227 @@
# Droid (Factory.ai) Provider
This adapter enables you to use Factory.ai's Droid CLI as an OpenAI-compatible API through AIClient2API.
## Features
- ✅ Uses your existing Droid CLI installation
- ✅ No need for API keys or token management
- ✅ Full Claude API compatibility
- ✅ Supports streaming and non-streaming responses
- ✅ Works with any OpenAI-compatible client
## Prerequisites
1. **Install Droid CLI**
```bash
# Install Droid CLI from Factory.ai
# Visit: https://factory.ai/product/cli
```
2. **Authenticate with Droid**
```bash
droid
# Follow the prompts to login
```
## Configuration
### Using Command Line Arguments
```bash
node src/api-server.js \
--model-provider droid-factory-oauth \
--port 3000 \
--api-key your-api-key
```
### Using config.json
Add to your `config.json`:
```json
{
"MODEL_PROVIDER": "droid-factory-oauth",
"PORT": 3000,
"API_KEY": "your-api-key"
}
```
### Using Environment Variables
```bash
export MODEL_PROVIDER=droid-factory-oauth
export PORT=3000
export API_KEY=your-api-key
node src/api-server.js
```
## Usage
### With OpenAI-Compatible Clients
Point your OpenAI-compatible client to the proxy:
```python
import openai
client = openai.OpenAI(
base_url="http://localhost:3000/v1",
api_key="your-api-key"
)
response = client.chat.completions.create(
model="claude-sonnet-4-5-20250929",
messages=[
{"role": "user", "content": "Hello!"}
]
)
```
### With Claude SDK
```python
import anthropic
client = anthropic.Anthropic(
base_url="http://localhost:3000",
api_key="your-api-key" # Your proxy API key, not Anthropic's
)
message = client.messages.create(
model="claude-sonnet-4-5-20250929",
max_tokens=1024,
messages=[
{"role": "user", "content": "Hello, Claude!"}
]
)
```
### With curl
```bash
# Claude API format
curl http://localhost:3000/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: your-api-key" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-5-20250929",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
# OpenAI API format
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key" \
-d '{
"model": "claude-sonnet-4-5-20250929",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'
```
## Supported Models
The Droid provider supports all Claude models available through Factory.ai:
- `claude-sonnet-4-5-20250929` (default)
- `claude-3-7-sonnet-20250219`
- `claude-3-5-sonnet-20241022`
- `claude-3-opus-20240229`
## How It Works
The proxy converts OpenAI/Claude API requests into `droid exec` commands and streams the results back:
1. Your app sends a request to the proxy (OpenAI or Claude format)
2. The proxy converts messages to a prompt
3. Executes `droid exec "your prompt"`
4. Streams the response back in the requested format
## Troubleshooting
### "Droid CLI is not installed or not in PATH"
**Problem**: The droid command is not available
**Solution**:
1. Install Droid CLI from https://factory.ai/product/cli
2. Make sure `droid` is in your system PATH
3. Test with: `droid --version`
### "Droid command failed"
**Problem**: Droid CLI returned an error
**Solution**:
1. Make sure you are authenticated: `droid`
2. Test droid directly: `droid exec "hello"`
3. Check for any error messages from the droid CLI
### Authentication Issues
**Problem**: Droid needs authentication
**Solution**:
1. Run `droid` to start an interactive session and authenticate
2. Follow the browser authentication flow
3. After authentication, the proxy will work automatically
## Architecture
```
Your App → AIClient2API Proxy → droid exec command → Factory.ai
(Port 3000) (CLI process)
```
The Droid provider:
1. Receives API requests (OpenAI or Claude format)
2. Converts messages to prompt text
3. Spawns `droid exec "prompt"` process
4. Streams output back to client
5. Converts response to requested API format
## Advanced Configuration
### Custom Droid Command
If your droid CLI is installed with a different name or path:
```json
{
"MODEL_PROVIDER": "droid-factory-oauth",
"DROID_COMMAND": "/custom/path/to/droid"
}
```
## Security Considerations
1. **API key protection**: Use strong API keys for the proxy itself
2. **Local execution**: Droid CLI runs locally with your credentials
3. **Use HTTPS in production**: Don't expose the proxy over HTTP in production
4. **Access control**: Limit who can access your proxy server
## Limitations
- Requires Droid CLI to be installed and authenticated
- Each request spawns a new `droid exec` process
- Streaming support depends on droid CLI output behavior
- Authentication is managed by Droid CLI (not the proxy)
## Contributing
When contributing Droid-related changes:
1. Test with actual Droid CLI installation
2. Ensure both streaming and non-streaming work
3. Update this README with any new features
4. Follow the existing code patterns in the project
## License
This provider follows the same license as the main AIClient2API project (GPLv3).

229
src/droid/droid-core.js Normal file
View file

@ -0,0 +1,229 @@
import { spawn } from 'child_process';
/**
* Droid (Factory.ai) API Service
* Wraps the Droid CLI to provide an API-compatible interface
*/
export class DroidApiService {
/**
* Constructor
* @param {object} config - Configuration object
*/
constructor(config = {}) {
this.config = config;
this.isInitialized = false;
this.droidCommand = config.DROID_COMMAND || 'droid';
}
/**
* Initialize the service by checking if droid CLI is available
* @returns {Promise<boolean>} True if initialization succeeded
*/
async initialize() {
try {
console.log('[Droid] Initializing Droid API Service...');
await this.executeDroidCommand(['--version']);
this.isInitialized = true;
console.log('[Droid] Initialization complete.');
return true;
} catch (error) {
console.error('[Droid] Droid CLI not available:', error.message);
this.isInitialized = false;
throw new Error('Droid CLI is not installed or not in PATH. Please install from https://factory.ai/product/cli');
}
}
/**
* Execute droid CLI command and return output
* @param {Array<string>} args - Command arguments
* @returns {Promise<string>} Command output
*/
async executeDroidCommand(args) {
return new Promise((resolve, reject) => {
const droid = spawn(this.droidCommand, args);
let stdout = '';
let stderr = '';
droid.stdout.on('data', (data) => {
stdout += data.toString();
});
droid.stderr.on('data', (data) => {
stderr += data.toString();
});
droid.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Droid command failed: ${stderr || stdout}`));
} else {
resolve(stdout);
}
});
droid.on('error', (err) => {
reject(new Error(`Failed to spawn droid: ${err.message}`));
});
});
}
/**
* Convert Claude-format messages to a simple prompt string
* @param {Array<object>} messages - Array of message objects
* @returns {string} Combined prompt string
*/
messagesToPrompt(messages) {
return messages.map(msg => {
if (msg.role === 'user') {
return msg.content;
} else if (msg.role === 'assistant') {
return `Assistant: ${msg.content}`;
} else if (msg.role === 'system') {
return `System: ${msg.content}`;
}
return msg.content;
}).join('\n\n');
}
/**
* Generates content (non-streaming)
* @param {string} model - Model name
* @param {object} requestBody - Request body (Claude format)
* @returns {Promise<object>} Claude API response (Claude compatible format)
*/
async generateContent(model, requestBody) {
if (!this.isInitialized) {
await this.initialize();
}
try {
const prompt = this.messagesToPrompt(requestBody.messages);
const output = await this.executeDroidCommand(['exec', prompt]);
return {
id: `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content: [{
type: 'text',
text: output.trim()
}],
model: model,
stop_reason: 'end_turn',
usage: {
input_tokens: Math.ceil(prompt.length / 4),
output_tokens: Math.ceil(output.length / 4)
}
};
} catch (error) {
throw this._handleError(error);
}
}
/**
* Streams content generation
* @param {string} model - Model name
* @param {object} requestBody - Request body (Claude format)
* @returns {AsyncIterable<object>} Claude API response stream (Claude compatible format)
*/
async *generateContentStream(model, requestBody) {
if (!this.isInitialized) {
await this.initialize();
}
const prompt = this.messagesToPrompt(requestBody.messages);
yield {
type: 'message_start',
message: {
id: `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content: [],
model: model
}
};
yield {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
};
const droid = spawn(this.droidCommand, ['exec', prompt]);
let buffer = '';
for await (const chunk of droid.stdout) {
const text = chunk.toString();
buffer += text;
yield {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: text }
};
}
yield {
type: 'content_block_stop',
index: 0
};
yield {
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: Math.ceil(buffer.length / 4) }
};
yield {
type: 'message_stop'
};
}
/**
* Lists available models
* The Droid provider supports Claude models available through Factory.ai
* @returns {Promise<object>} List of models
*/
async listModels() {
console.log('[Droid] Listing available models.');
return {
data: [
{
id: 'claude-sonnet-4-5-20250929',
object: 'model',
created: 1725494400,
owned_by: 'anthropic'
},
{
id: 'claude-3-7-sonnet-20250219',
object: 'model',
created: 1708387200,
owned_by: 'anthropic'
},
{
id: 'claude-3-5-sonnet-20241022',
object: 'model',
created: 1698019200,
owned_by: 'anthropic'
},
{
id: 'claude-3-opus-20240229',
object: 'model',
created: 1709251200,
owned_by: 'anthropic'
}
],
object: 'list'
};
}
/**
* Handle CLI errors
* @param {Error} error - Error object
* @returns {Error} Formatted error
*/
_handleError(error) {
console.error('[Droid] Error:', error.message);
return new Error(`Droid CLI error: ${error.message}`);
}
}

View file

@ -0,0 +1,74 @@
import { ProviderStrategy } from '../provider-strategy.js';
import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../common.js';
/**
* Droid (Factory.ai) provider strategy implementation
* Follows Claude protocol since Droid uses Claude API
*/
class DroidStrategy extends ProviderStrategy {
extractModelAndStreamInfo(req, requestBody) {
const model = requestBody.model || 'claude-sonnet-4-5-20250929';
const isStream = requestBody.stream === true;
return { model, isStream };
}
extractResponseText(response) {
if (response.type === 'content_block_delta' && response.delta) {
if (response.delta.type === 'text_delta') {
return response.delta.text;
}
if (response.delta.type === 'input_json_delta') {
return response.delta.partial_json;
}
}
if (response.content && Array.isArray(response.content)) {
return response.content
.filter(block => block.type === 'text' && block.text)
.map(block => block.text)
.join('');
} else if (response.content && response.content.type === 'text') {
return response.content.text;
}
return '';
}
extractPromptText(requestBody) {
if (requestBody.messages && requestBody.messages.length > 0) {
const lastMessage = requestBody.messages[requestBody.messages.length - 1];
if (lastMessage.content && Array.isArray(lastMessage.content)) {
return lastMessage.content.map(block => block.text || '').join('');
}
return lastMessage.content || '';
}
return '';
}
async applySystemPromptFromFile(config, requestBody) {
if (!config.SYSTEM_PROMPT_FILE_PATH) {
return requestBody;
}
const filePromptContent = config.SYSTEM_PROMPT_CONTENT;
if (filePromptContent === null) {
return requestBody;
}
const existingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.CLAUDE);
const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText
? `${existingSystemText}\n${filePromptContent}`
: filePromptContent;
requestBody.system = newSystemText;
console.log(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'droid'.`);
return requestBody;
}
async manageSystemPrompt(requestBody) {
const incomingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.CLAUDE);
await this._updateSystemPromptFile(incomingSystemText, 'droid');
}
}
export { DroidStrategy };

View file

@ -2,6 +2,7 @@ import { MODEL_PROTOCOL_PREFIX } from './common.js';
import { GeminiStrategy } from './gemini/gemini-strategy.js';
import { OpenAIStrategy } from './openai/openai-strategy.js';
import { ClaudeStrategy } from './claude/claude-strategy.js';
import { DroidStrategy } from './droid/droid-strategy.js';
/**
* Strategy factory that returns the appropriate strategy instance based on the provider protocol.
@ -15,6 +16,8 @@ class ProviderStrategyFactory {
return new OpenAIStrategy();
case MODEL_PROTOCOL_PREFIX.CLAUDE:
return new ClaudeStrategy();
case MODEL_PROTOCOL_PREFIX.DROID:
return new DroidStrategy(); // Droid uses Claude-compatible protocol
default:
throw new Error(`Unsupported provider protocol: ${providerProtocol}`);
}

59
test-droid.js Normal file
View file

@ -0,0 +1,59 @@
// Test script for Droid provider
import { DroidApiService } from './src/droid/droid-core.js';
import { promises as fs } from 'fs';
async function testDroidProvider() {
console.log('🧪 Testing Droid Provider (CLI-based)...\n');
try {
// Test 1: Initialize DroidApiService (check droid CLI availability)
console.log('✅ Test 1: Initializing DroidApiService...');
const service = new DroidApiService();
await service.initialize();
console.log(' DroidApiService initialized successfully');
console.log(' isInitialized:', service.isInitialized);
// Test 2: List models
console.log('\n✅ Test 2: Listing available models...');
const models = await service.listModels();
console.log(` Available models: ${models.data.length}`);
models.data.forEach(model => {
console.log(` - ${model.id}`);
});
// Test 3: Test simple request (non-streaming)
console.log('\n✅ Test 3: Testing simple request...');
try {
const response = await service.generateContent('claude-sonnet-4-5-20250929', {
messages: [
{ role: 'user', content: 'Say "Hello from Droid test!" in one sentence.' }
],
max_tokens: 50
});
console.log(' Response received:');
console.log(' Model:', response.model);
console.log(' Stop reason:', response.stop_reason);
if (response.content && response.content[0]) {
console.log(' Content:', response.content[0].text);
}
} catch (error) {
console.error(' ❌ Request failed:', error.message);
console.log(' 💡 Make sure you are authenticated with: droid');
}
console.log('\n✅ All tests completed!');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
console.error('Stack:', error.stack);
}
}
// Run tests
testDroidProvider().then(() => {
console.log('\n🎉 Test suite finished');
process.exit(0);
}).catch(error => {
console.error('\n💥 Test suite error:', error);
process.exit(1);
});