mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
feat(core): add Requesty provider (#5478)
* feat(core): add Requesty provider Requesty (https://requesty.ai) is an OpenAI-compatible model gateway that uses the same provider/model identifier format as OpenRouter, so it is added by mirroring the existing OpenRouter provider. - RequestyOpenAICompatibleProvider + isRequestyProvider detection, mirroring the OpenRouter provider (base https://router.requesty.ai/v1, attribution headers) - register in the openaiContentGenerator dispatch + provider preset registry - auth migration entry + docs (auth, model-providers) Signed-off-by: Thibault Jaigu <thibault.jaigu@gmail.com> * fix(core): harden Requesty provider hostname detection Address review feedback on isRequestyProvider: - Replace substring matching (baseURL.includes) with URL-parsed hostname detection (host === 'router.requesty.ai' || host.endsWith('.requesty.ai')), matching the ownsModel gate in presets/requesty.ts and the MiMo/MiniMax/ Mistral providers. Rejects crafted URLs like router.requesty.ai.evil.com. - Add hostile-hostname rejection tests and determineProvider dispatch tests, matching the coverage in the other provider suites. --------- Signed-off-by: Thibault Jaigu <thibault.jaigu@gmail.com>
This commit is contained in:
parent
ca7638e85c
commit
a6e206c887
12 changed files with 449 additions and 8 deletions
|
|
@ -101,7 +101,7 @@ If you prefer to skip the interactive `/auth` flow, add the following to `~/.qwe
|
|||
|
||||
## 🚀 Option 3: API Key (flexible)
|
||||
|
||||
Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, ModelScope, or a self-hosted endpoint. Supports multiple protocols and providers.
|
||||
Use this if you want to connect to third-party providers such as OpenAI, Anthropic, Google, Azure OpenAI, OpenRouter, Requesty, ModelScope, or a self-hosted endpoint. Supports multiple protocols and providers.
|
||||
|
||||
### Recommended: One-file setup via `settings.json`
|
||||
|
||||
|
|
@ -153,11 +153,11 @@ The key concept is **Model Providers** (`modelProviders`): Qwen Code supports mu
|
|||
|
||||
#### Supported protocols
|
||||
|
||||
| Protocol | `modelProviders` key | Environment variables | Providers |
|
||||
| ----------------- | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude |
|
||||
| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini |
|
||||
| Protocol | `modelProviders` key | Environment variables | Providers |
|
||||
| ----------------- | -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| OpenAI-compatible | `openai` | `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | OpenAI, Azure OpenAI, OpenRouter, Requesty, ModelScope, Alibaba Cloud, any OpenAI-compatible endpoint |
|
||||
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL` | Anthropic Claude |
|
||||
| Google GenAI | `gemini` | `GEMINI_API_KEY`, `GEMINI_MODEL` | Google Gemini |
|
||||
|
||||
#### Step 1: Configure models and providers in `~/.qwen/settings.json`
|
||||
|
||||
|
|
@ -315,6 +315,7 @@ The standalone `qwen auth` CLI command has been removed. Use these replacements
|
|||
| Interactive authentication setup | Run `qwen`, then use `/auth` |
|
||||
| Coding Plan setup | Use `/auth`, or set `BAILIAN_CODING_PLAN_API_KEY` with the Coding Plan base URL |
|
||||
| OpenRouter setup | Use `/auth`, or set `OPENROUTER_API_KEY` and `OPENAI_BASE_URL=https://openrouter.ai/api/v1` |
|
||||
| Requesty setup | Use `/auth`, or set `REQUESTY_API_KEY` and `OPENAI_BASE_URL=https://router.requesty.ai/v1` |
|
||||
| API-key or custom provider setup | Configure `~/.qwen/settings.json`, `.env`, or provider-specific environment variables |
|
||||
| Check current authentication | Run `/doctor` inside Qwen Code |
|
||||
| OAuth browser flow | Run `qwen` interactively and use `/auth`; OAuth cannot be configured with env vars alone |
|
||||
|
|
|
|||
|
|
@ -47,13 +47,14 @@ This means the `baseUrl` you configure should be compatible with the correspondi
|
|||
|
||||
### OpenAI-compatible providers (`openai`)
|
||||
|
||||
This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter.
|
||||
This auth type supports not only OpenAI's official API but also any OpenAI-compatible endpoint, including aggregated model providers like OpenRouter and Requesty.
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-your-actual-openai-key-here",
|
||||
"OPENROUTER_API_KEY": "sk-or-your-actual-openrouter-key-here"
|
||||
"OPENROUTER_API_KEY": "sk-or-your-actual-openrouter-key-here",
|
||||
"REQUESTY_API_KEY": "sk-your-actual-requesty-key-here"
|
||||
},
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
|
|
@ -111,6 +112,19 @@ This auth type supports not only OpenAI's official API but also any OpenAI-compa
|
|||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai/gpt-4o-mini",
|
||||
"name": "GPT-4o Mini (via Requesty)",
|
||||
"envKey": "REQUESTY_API_KEY",
|
||||
"baseUrl": "https://router.requesty.ai/v1",
|
||||
"generationConfig": {
|
||||
"timeout": 120000,
|
||||
"maxRetries": 3,
|
||||
"samplingParams": {
|
||||
"temperature": 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const buildRemovalNotice = (): string =>
|
|||
` ${t('China: https://coding.dashscope.aliyuncs.com/v1')}`,
|
||||
` ${t('International: https://coding-intl.dashscope.aliyuncs.com/v1')}`,
|
||||
` ${cyan(t('OpenRouter'))} → ${t('set OPENROUTER_API_KEY and OPENAI_BASE_URL=https://openrouter.ai/api/v1')}`,
|
||||
` ${cyan(t('Requesty'))} → ${t('set REQUESTY_API_KEY and OPENAI_BASE_URL=https://router.requesty.ai/v1')}`,
|
||||
` ${cyan(t('Qwen OAuth'))} → ${t('run qwen interactively and use /auth; OAuth cannot be configured with env vars alone')}`,
|
||||
` ${cyan(t('Scripted'))} → ${t('edit ~/.qwen/settings.json, or run qwen interactively once')}`,
|
||||
'',
|
||||
|
|
@ -43,6 +44,7 @@ const legacySubcommands = [
|
|||
'status',
|
||||
'coding-plan',
|
||||
'openrouter',
|
||||
'requesty',
|
||||
'api-key',
|
||||
'qwen-oauth',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
MiniMaxOpenAICompatibleProvider,
|
||||
MistralOpenAICompatibleProvider,
|
||||
OpenRouterOpenAICompatibleProvider,
|
||||
RequestyOpenAICompatibleProvider,
|
||||
type OpenAICompatibleProvider,
|
||||
DefaultOpenAICompatibleProvider,
|
||||
} from './provider/index.js';
|
||||
|
|
@ -34,6 +35,7 @@ export {
|
|||
MiniMaxOpenAICompatibleProvider,
|
||||
MistralOpenAICompatibleProvider,
|
||||
OpenRouterOpenAICompatibleProvider,
|
||||
RequestyOpenAICompatibleProvider,
|
||||
} from './provider/index.js';
|
||||
|
||||
export { OpenAIContentConverter } from './converter.js';
|
||||
|
|
@ -90,6 +92,14 @@ export function determineProvider(
|
|||
);
|
||||
}
|
||||
|
||||
// Check for Requesty provider
|
||||
if (RequestyOpenAICompatibleProvider.isRequestyProvider(config)) {
|
||||
return new RequestyOpenAICompatibleProvider(
|
||||
contentGeneratorConfig,
|
||||
cliConfig,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for ModelScope provider
|
||||
if (ModelScopeOpenAICompatibleProvider.isModelScopeProvider(config)) {
|
||||
return new ModelScopeOpenAICompatibleProvider(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ This folder contains the different provider implementations for the Qwen Code re
|
|||
- `default.ts` - Default provider for standard OpenAI-compatible APIs
|
||||
- `dashscope.ts` - DashScope (Qwen) specific provider implementation
|
||||
- `openrouter.ts` - OpenRouter specific provider implementation
|
||||
- `requesty.ts` - Requesty specific provider implementation
|
||||
- `index.ts` - Main export file for all providers
|
||||
|
||||
## Provider Types
|
||||
|
|
@ -25,6 +26,10 @@ The `DashScopeOpenAICompatibleProvider` handles DashScope (Qwen) specific featur
|
|||
|
||||
The `OpenRouterOpenAICompatibleProvider` handles OpenRouter specific headers and configurations.
|
||||
|
||||
### Requesty Provider
|
||||
|
||||
The `RequestyOpenAICompatibleProvider` handles Requesty specific attribution headers and configurations.
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
To add a new provider:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export { ModelScopeOpenAICompatibleProvider } from './modelscope.js';
|
|||
export { DashScopeOpenAICompatibleProvider } from './dashscope.js';
|
||||
export { DeepSeekOpenAICompatibleProvider } from './deepseek.js';
|
||||
export { OpenRouterOpenAICompatibleProvider } from './openrouter.js';
|
||||
export { RequestyOpenAICompatibleProvider } from './requesty.js';
|
||||
export { MiniMaxOpenAICompatibleProvider } from './minimax.js';
|
||||
export { MistralOpenAICompatibleProvider } from './mistral.js';
|
||||
export { MiMoOpenAICompatibleProvider } from './mimo.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type OpenAI from 'openai';
|
||||
import { RequestyOpenAICompatibleProvider } from './requesty.js';
|
||||
import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
import { determineProvider } from '../index.js';
|
||||
import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
|
||||
describe('RequestyOpenAICompatibleProvider', () => {
|
||||
let provider: RequestyOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
let mockCliConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
apiKey: 'test-api-key',
|
||||
baseUrl: 'https://router.requesty.ai/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
model: 'openai/gpt-4o-mini',
|
||||
} as ContentGeneratorConfig;
|
||||
|
||||
// Mock Config
|
||||
mockCliConfig = {
|
||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
} as unknown as Config;
|
||||
|
||||
provider = new RequestyOpenAICompatibleProvider(
|
||||
mockContentGeneratorConfig,
|
||||
mockCliConfig,
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should extend DefaultOpenAICompatibleProvider', () => {
|
||||
expect(provider).toBeInstanceOf(DefaultOpenAICompatibleProvider);
|
||||
expect(provider).toBeInstanceOf(RequestyOpenAICompatibleProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRequestyProvider', () => {
|
||||
it('should return true for router.requesty.ai URLs', () => {
|
||||
const configs = [
|
||||
{ baseUrl: 'https://router.requesty.ai/v1' },
|
||||
{ baseUrl: 'https://router.requesty.ai' },
|
||||
{ baseUrl: 'http://router.requesty.ai/v1' },
|
||||
];
|
||||
|
||||
configs.forEach((config) => {
|
||||
const result = RequestyOpenAICompatibleProvider.isRequestyProvider(
|
||||
config as ContentGeneratorConfig,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false for non-requesty URLs', () => {
|
||||
const configs = [
|
||||
{ baseUrl: 'https://api.openai.com/v1' },
|
||||
{ baseUrl: 'https://api.anthropic.com/v1' },
|
||||
{ baseUrl: 'https://openrouter.ai/api/v1' },
|
||||
{ baseUrl: 'https://example.com/api/v1' }, // different domain
|
||||
{ baseUrl: '' },
|
||||
{ baseUrl: undefined },
|
||||
];
|
||||
|
||||
configs.forEach((config) => {
|
||||
const result = RequestyOpenAICompatibleProvider.isRequestyProvider(
|
||||
config as ContentGeneratorConfig,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing baseUrl gracefully', () => {
|
||||
const config = {} as ContentGeneratorConfig;
|
||||
const result =
|
||||
RequestyOpenAICompatibleProvider.isRequestyProvider(config);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject hostile hostnames containing router.requesty.ai', () => {
|
||||
const configs = [
|
||||
{ baseUrl: 'https://router.requesty.ai.evil.example/v1' },
|
||||
{ baseUrl: 'https://fake-router.requesty.ai.attacker.com/v1' },
|
||||
{ baseUrl: 'https://router.requesty.ai@evil.example/v1' },
|
||||
{ baseUrl: 'not-a-url' },
|
||||
];
|
||||
|
||||
configs.forEach((config) => {
|
||||
const result = RequestyOpenAICompatibleProvider.isRequestyProvider(
|
||||
config as ContentGeneratorConfig,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineProvider dispatch', () => {
|
||||
it('routes router.requesty.ai configs to RequestyOpenAICompatibleProvider', () => {
|
||||
const provider = determineProvider(
|
||||
{
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://router.requesty.ai/v1',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
} as ContentGeneratorConfig,
|
||||
mockCliConfig,
|
||||
);
|
||||
expect(provider).toBeInstanceOf(RequestyOpenAICompatibleProvider);
|
||||
});
|
||||
|
||||
it('does not route hostile hostnames to RequestyOpenAICompatibleProvider', () => {
|
||||
const provider = determineProvider(
|
||||
{
|
||||
apiKey: 'key',
|
||||
baseUrl: 'https://router.requesty.ai.evil.example/v1',
|
||||
model: 'openai/gpt-4o-mini',
|
||||
} as ContentGeneratorConfig,
|
||||
mockCliConfig,
|
||||
);
|
||||
expect(provider).not.toBeInstanceOf(RequestyOpenAICompatibleProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaders', () => {
|
||||
it('should include base headers from parent class', () => {
|
||||
const headers = provider.buildHeaders();
|
||||
|
||||
// Should include User-Agent from parent
|
||||
expect(headers['User-Agent']).toBe(
|
||||
`QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add Requesty-specific headers', () => {
|
||||
const headers = provider.buildHeaders();
|
||||
|
||||
expect(headers).toEqual({
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git',
|
||||
'X-Title': 'Qwen Code',
|
||||
});
|
||||
});
|
||||
|
||||
it('should override parent headers if there are conflicts', () => {
|
||||
// Mock parent to return conflicting headers
|
||||
const parentBuildHeaders = vi.spyOn(
|
||||
DefaultOpenAICompatibleProvider.prototype,
|
||||
'buildHeaders',
|
||||
);
|
||||
parentBuildHeaders.mockReturnValue({
|
||||
'User-Agent': 'ParentAgent/1.0.0',
|
||||
'HTTP-Referer': 'https://parent.com',
|
||||
});
|
||||
|
||||
const headers = provider.buildHeaders();
|
||||
|
||||
expect(headers).toEqual({
|
||||
'User-Agent': 'ParentAgent/1.0.0',
|
||||
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git', // Requesty-specific value should override
|
||||
'X-Title': 'Qwen Code',
|
||||
});
|
||||
|
||||
parentBuildHeaders.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle unknown CLI version from parent', () => {
|
||||
vi.mocked(mockCliConfig.getCliVersion).mockReturnValue(undefined);
|
||||
|
||||
const headers = provider.buildHeaders();
|
||||
|
||||
expect(headers['User-Agent']).toBe(
|
||||
`QwenCode/unknown (${process.platform}; ${process.arch})`,
|
||||
);
|
||||
expect(headers['HTTP-Referer']).toBe(
|
||||
'https://github.com/QwenLM/qwen-code.git',
|
||||
);
|
||||
expect(headers['X-Title']).toBe('Qwen Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClient', () => {
|
||||
it('should inherit buildClient behavior from parent', () => {
|
||||
// Mock the parent's buildClient method
|
||||
const mockClient = { test: 'client' };
|
||||
const parentBuildClient = vi.spyOn(
|
||||
DefaultOpenAICompatibleProvider.prototype,
|
||||
'buildClient',
|
||||
);
|
||||
parentBuildClient.mockReturnValue(mockClient as unknown as OpenAI);
|
||||
|
||||
const result = provider.buildClient();
|
||||
|
||||
expect(parentBuildClient).toHaveBeenCalled();
|
||||
expect(result).toBe(mockClient);
|
||||
|
||||
parentBuildClient.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRequest', () => {
|
||||
it('should inherit buildRequest behavior from parent', () => {
|
||||
const mockRequest: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
model: 'openai/gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
};
|
||||
const mockUserPromptId = 'test-prompt-id';
|
||||
const mockResult = { ...mockRequest, modified: true };
|
||||
|
||||
// Mock the parent's buildRequest method
|
||||
const parentBuildRequest = vi.spyOn(
|
||||
DefaultOpenAICompatibleProvider.prototype,
|
||||
'buildRequest',
|
||||
);
|
||||
parentBuildRequest.mockReturnValue(mockResult);
|
||||
|
||||
const result = provider.buildRequest(mockRequest, mockUserPromptId);
|
||||
|
||||
expect(parentBuildRequest).toHaveBeenCalledWith(
|
||||
mockRequest,
|
||||
mockUserPromptId,
|
||||
);
|
||||
expect(result).toBe(mockResult);
|
||||
|
||||
parentBuildRequest.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with parent class', () => {
|
||||
it('should properly call parent constructor', () => {
|
||||
const newProvider = new RequestyOpenAICompatibleProvider(
|
||||
mockContentGeneratorConfig,
|
||||
mockCliConfig,
|
||||
);
|
||||
|
||||
// Verify that parent properties are accessible
|
||||
expect(newProvider).toHaveProperty('buildHeaders');
|
||||
expect(newProvider).toHaveProperty('buildClient');
|
||||
expect(newProvider).toHaveProperty('buildRequest');
|
||||
});
|
||||
|
||||
it('should maintain parent functionality while adding Requesty specifics', () => {
|
||||
// Test that the provider can perform all parent operations
|
||||
const headers = provider.buildHeaders();
|
||||
|
||||
// Should have both parent and Requesty-specific headers
|
||||
expect(headers['User-Agent']).toBeDefined(); // From parent
|
||||
expect(headers['HTTP-Referer']).toBe(
|
||||
'https://github.com/QwenLM/qwen-code.git',
|
||||
); // Requesty-specific
|
||||
expect(headers['X-Title']).toBe('Qwen Code'); // Requesty-specific
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
|
||||
export class RequestyOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider {
|
||||
constructor(
|
||||
contentGeneratorConfig: ContentGeneratorConfig,
|
||||
cliConfig: Config,
|
||||
) {
|
||||
super(contentGeneratorConfig, cliConfig);
|
||||
}
|
||||
|
||||
static isRequestyProvider(
|
||||
contentGeneratorConfig: ContentGeneratorConfig,
|
||||
): boolean {
|
||||
const baseURL = contentGeneratorConfig.baseUrl || '';
|
||||
if (!baseURL) return false;
|
||||
try {
|
||||
const host = new URL(baseURL).hostname.toLowerCase();
|
||||
return host === 'router.requesty.ai' || host.endsWith('.requesty.ai');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
override buildHeaders(): Record<string, string | undefined> {
|
||||
// Get base headers from parent class
|
||||
const baseHeaders = super.buildHeaders();
|
||||
|
||||
// Add Requesty-specific attribution headers
|
||||
return {
|
||||
...baseHeaders,
|
||||
'HTTP-Referer': 'https://github.com/QwenLM/qwen-code.git',
|
||||
'X-Title': 'Qwen Code',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// Re-import via the relative source path so the new ownsModel envKey gate
|
||||
// is exercised even before dist/ is rebuilt (the @qwen-code/qwen-code-core
|
||||
// package resolves to dist/ on a fresh branch).
|
||||
import { requestyProvider, REQUESTY_ENV_KEY } from '../../presets/requesty.js';
|
||||
|
||||
describe('requestyProvider', () => {
|
||||
it('owns models that match BOTH our envKey and a router.requesty.ai host', () => {
|
||||
expect(
|
||||
requestyProvider.ownsModel?.({
|
||||
id: 'requesty-model',
|
||||
baseUrl: 'https://router.requesty.ai/v1',
|
||||
envKey: REQUESTY_ENV_KEY,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses ownership over a different envKey on the same host (user-added entry)', () => {
|
||||
// A user wired their own gateway through router.requesty.ai with a custom
|
||||
// env var — re-install must not silently delete their model entry.
|
||||
expect(
|
||||
requestyProvider.ownsModel?.({
|
||||
id: 'user-added',
|
||||
baseUrl: 'https://router.requesty.ai/v1',
|
||||
envKey: 'MY_PRIVATE_GATEWAY_KEY',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses ownership over an unrelated host even with our envKey', () => {
|
||||
expect(
|
||||
requestyProvider.ownsModel?.({
|
||||
id: 'other-model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
envKey: REQUESTY_ENV_KEY,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses ownership when baseUrl is missing or malformed', () => {
|
||||
expect(
|
||||
requestyProvider.ownsModel?.({
|
||||
id: 'no-url',
|
||||
envKey: REQUESTY_ENV_KEY,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
requestyProvider.ownsModel?.({
|
||||
id: 'bad-url',
|
||||
baseUrl: 'not a url',
|
||||
envKey: REQUESTY_ENV_KEY,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import { codingPlanProvider } from './presets/alibaba-coding-plan.js';
|
|||
import { tokenPlanProvider } from './presets/alibaba-token-plan.js';
|
||||
import { alibabaStandardProvider } from './presets/alibaba-standard.js';
|
||||
import { openRouterProvider } from './presets/openrouter.js';
|
||||
import { requestyProvider } from './presets/requesty.js';
|
||||
import { deepseekProvider } from './presets/deepseek.js';
|
||||
import { minimaxProvider } from './presets/minimax.js';
|
||||
import { zaiProvider } from './presets/zai.js';
|
||||
|
|
@ -26,6 +27,7 @@ export {
|
|||
tokenPlanProvider,
|
||||
alibabaStandardProvider,
|
||||
openRouterProvider,
|
||||
requestyProvider,
|
||||
deepseekProvider,
|
||||
minimaxProvider,
|
||||
zaiProvider,
|
||||
|
|
@ -53,6 +55,7 @@ export const ALL_PROVIDERS: readonly ProviderConfig[] = [
|
|||
idealabProvider,
|
||||
modelscopeProvider,
|
||||
openRouterProvider,
|
||||
requestyProvider,
|
||||
customProvider,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export {
|
|||
minimaxProvider,
|
||||
modelscopeProvider,
|
||||
openRouterProvider,
|
||||
requestyProvider,
|
||||
THIRD_PARTY_PROVIDERS,
|
||||
tokenPlanProvider,
|
||||
zaiProvider,
|
||||
|
|
@ -69,6 +70,7 @@ export {
|
|||
OPENROUTER_BASE_URL,
|
||||
OPENROUTER_ENV_KEY,
|
||||
} from './presets/openrouter.js';
|
||||
export { REQUESTY_BASE_URL, REQUESTY_ENV_KEY } from './presets/requesty.js';
|
||||
|
||||
// Install logic
|
||||
export {
|
||||
|
|
|
|||
42
packages/core/src/providers/presets/requesty.ts
Normal file
42
packages/core/src/providers/presets/requesty.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '../../core/contentGenerator.js';
|
||||
import type { ProviderConfig } from '../types.js';
|
||||
|
||||
export const REQUESTY_ENV_KEY = 'REQUESTY_API_KEY';
|
||||
export const REQUESTY_BASE_URL = 'https://router.requesty.ai/v1';
|
||||
|
||||
export const requestyProvider: ProviderConfig = {
|
||||
id: 'requesty',
|
||||
label: 'Requesty',
|
||||
description:
|
||||
'Connect with a Requesty API key (get one from app.requesty.ai/api-keys)',
|
||||
protocol: AuthType.USE_OPENAI,
|
||||
baseUrl: REQUESTY_BASE_URL,
|
||||
envKey: REQUESTY_ENV_KEY,
|
||||
models: [
|
||||
{ id: 'openai/gpt-4o-mini', contextWindowSize: 128000 },
|
||||
{ id: 'openai/gpt-4o', contextWindowSize: 128000 },
|
||||
],
|
||||
modelsEditable: true,
|
||||
modelNamePrefix: 'Requesty',
|
||||
ownsModel: (model) => {
|
||||
// A user who manually added a Requesty-routed model under a custom
|
||||
// envKey (e.g. their own gateway) shouldn't have their entry silently
|
||||
// removed on re-install — require BOTH the hostname *and* our envKey to
|
||||
// claim ownership.
|
||||
if (model.envKey !== REQUESTY_ENV_KEY) return false;
|
||||
try {
|
||||
const host = new URL(model.baseUrl ?? '').hostname;
|
||||
return host === 'router.requesty.ai' || host.endsWith('.requesty.ai');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
documentationUrl: 'https://docs.requesty.ai',
|
||||
uiGroup: 'third-party',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue