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:
Thibault Jaigu 2026-06-20 23:37:24 +01:00 committed by GitHub
parent ca7638e85c
commit a6e206c887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 449 additions and 8 deletions

View file

@ -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 |

View file

@ -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
}
}
}
]
}

View file

@ -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',
];

View file

@ -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(

View file

@ -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:

View file

@ -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';

View file

@ -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
});
});
});

View file

@ -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',
};
}
}

View file

@ -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);
});
});

View file

@ -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,
];

View file

@ -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 {

View 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',
};