mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-19 23:36:19 +00:00
Compare commits
26 Commits
feature/ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de47c4e98b | ||
|
|
eed46447da | ||
|
|
8de81b6299 | ||
|
|
b13c5bf090 | ||
|
|
0a64fa78f5 | ||
|
|
f99295462d | ||
|
|
1145045a5a | ||
|
|
95c551c1b4 | ||
|
|
e8356c5f9e | ||
|
|
dc067697dc | ||
|
|
ced1b1db80 | ||
|
|
cf140b1b9d | ||
|
|
1f1e78aa3b | ||
|
|
511269446f | ||
|
|
0901b228a7 | ||
|
|
da8c49cb9d | ||
|
|
d7d3371ddf | ||
|
|
4213d06ab9 | ||
|
|
45236b6ec5 | ||
|
|
9e8724a749 | ||
|
|
d91e372c72 | ||
|
|
9325721811 | ||
|
|
56391b11ad | ||
|
|
e748532e6d | ||
|
|
d095a8b3f1 | ||
|
|
f7585153b7 |
@@ -120,7 +120,6 @@ Settings are organized into categories. All settings should be placed within the
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"disableCacheControl": false,
|
||||
"contextWindowSize": 128000,
|
||||
"customHeaders": {
|
||||
"X-Request-ID": "req-123",
|
||||
"X-User-ID": "user-456"
|
||||
@@ -135,46 +134,6 @@ Settings are organized into categories. All settings should be placed within the
|
||||
}
|
||||
```
|
||||
|
||||
**contextWindowSize:**
|
||||
|
||||
The `contextWindowSize` field allows you to manually override the automatic context window size detection. This is useful when you want to:
|
||||
|
||||
- **Optimize performance**: Limit context size to improve response speed
|
||||
- **Control costs**: Reduce token usage to lower API call costs
|
||||
- **Handle specific requirements**: Set a custom limit when automatic detection doesn't match your needs
|
||||
- **Testing scenarios**: Use smaller context windows in test environments
|
||||
|
||||
**Values:**
|
||||
|
||||
- `-1` (default): Use automatic detection based on the model's capabilities
|
||||
- Positive number: Manually specify the context window size in tokens (e.g., `128000` for 128k tokens)
|
||||
|
||||
**Example with contextWindowSize:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"contextWindowSize": 128000 // Override to 128k tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use `-1` for automatic detection:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"contextWindowSize": -1 // Auto-detect based on model (default)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** User-configured `contextWindowSize` > Automatic detection > Default value
|
||||
|
||||
The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels.
|
||||
|
||||
**model.openAILoggingDir examples:**
|
||||
@@ -315,7 +274,6 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
|
||||
| `tools.autoAccept` | boolean | Controls whether the CLI automatically accepts and executes tool calls that are considered safe (e.g., read-only operations) without explicit user confirmation. If set to `true`, the CLI will bypass the confirmation prompt for tools deemed safe. | `false` | |
|
||||
| `tools.experimental.skills` | boolean | Enable experimental Agent Skills feature | `false` | |
|
||||
|
||||
#### mcp
|
||||
|
||||
@@ -352,6 +310,12 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
||||
>
|
||||
> **Note about advanced.tavilyApiKey:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
|
||||
|
||||
#### experimental
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| --------------------- | ------- | -------------------------------- | ------- |
|
||||
| `experimental.skills` | boolean | Enable experimental Agent Skills | `false` |
|
||||
|
||||
#### mcpServers
|
||||
|
||||
Configures connections to one or more Model-Context Protocol (MCP) servers for discovering and using custom tools. Qwen Code attempts to connect to each configured MCP server to discover available tools. If multiple MCP servers expose a tool with the same name, the tool names will be prefixed with the server alias you defined in the configuration (e.g., `serverAlias__actualToolName`) to avoid conflicts. Note that the system might strip certain schema properties from MCP tool definitions for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`.
|
||||
|
||||
@@ -311,9 +311,9 @@ function setupAcpTest(
|
||||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting approval mode', async () => {
|
||||
it('returns modes on initialize and allows setting mode and model', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp approval mode');
|
||||
rig.setup('acp mode and model');
|
||||
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
@@ -366,8 +366,14 @@ function setupAcpTest(
|
||||
const newSession = (await sendRequest('session/new', {
|
||||
cwd: rig.testDir!,
|
||||
mcpServers: [],
|
||||
})) as { sessionId: string };
|
||||
})) as {
|
||||
sessionId: string;
|
||||
models: {
|
||||
availableModels: Array<{ modelId: string }>;
|
||||
};
|
||||
};
|
||||
expect(newSession.sessionId).toBeTruthy();
|
||||
expect(newSession.models.availableModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 4: Set approval mode to 'yolo'
|
||||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
@@ -392,6 +398,15 @@ function setupAcpTest(
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
|
||||
// Test 7: Set model using first available model
|
||||
const firstModel = newSession.models.availableModels[0];
|
||||
const setModelResult = (await sendRequest('session/set_model', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: firstModel.modelId,
|
||||
})) as { modelId: string };
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(setModelResult.modelId).toBeTruthy();
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
|
||||
@@ -70,6 +70,13 @@ export class AgentSideConnection implements Client {
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
@@ -408,4 +415,5 @@ export interface Agent {
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
}
|
||||
|
||||
@@ -165,30 +165,11 @@ class GeminiAgent {
|
||||
this.setupFileSystem(config);
|
||||
|
||||
const session = await this.createAndStoreSession(config);
|
||||
const configuredModel = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const modelId = configuredModel || 'default';
|
||||
const modelName = configuredModel || modelId;
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
|
||||
return {
|
||||
sessionId: session.getId(),
|
||||
models: {
|
||||
currentModelId: modelId,
|
||||
availableModels: [
|
||||
{
|
||||
modelId,
|
||||
name: modelName,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(modelId),
|
||||
},
|
||||
},
|
||||
],
|
||||
_meta: null,
|
||||
},
|
||||
models: availableModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,15 +286,29 @@ class GeminiAgent {
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params);
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
throw acp.RequestError.authRequired(
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -382,4 +377,43 @@ class GeminiAgent {
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
const currentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
''
|
||||
).trim();
|
||||
const availableModels = config.getAvailableModels();
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => ({
|
||||
modelId: model.id,
|
||||
name: model.label,
|
||||
description: model.description ?? null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(model.id),
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
currentModelId &&
|
||||
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
|
||||
) {
|
||||
mappedAvailableModels.unshift({
|
||||
modelId: currentModelId,
|
||||
name: currentModelId,
|
||||
description: null,
|
||||
_meta: {
|
||||
contextLimit: tokenLimit(currentModelId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentModelId,
|
||||
availableModels: mappedAvailableModels,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const AGENT_METHODS = {
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
@@ -266,6 +267,18 @@ export const modelInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
@@ -592,6 +605,7 @@ export const agentResponseSchema = z.union([
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
@@ -624,6 +638,7 @@ export const agentRequestSchema = z.union([
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
||||
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
174
packages/cli/src/acp-integration/session/Session.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Session } from './Session.js';
|
||||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
||||
getAvailableCommands: vi.fn(),
|
||||
handleSlashCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let getAvailableCommandsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
currentModel = 'qwen3-code-plus';
|
||||
setModelSpy = vi.fn().mockImplementation(async (modelId: string) => {
|
||||
currentModel = modelId;
|
||||
});
|
||||
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
|
||||
mockConfig = {
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: setModelSpy,
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
} as unknown as Config;
|
||||
|
||||
mockClient = {
|
||||
sessionUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands)
|
||||
.getAvailableCommands as unknown as ReturnType<typeof vi.fn>;
|
||||
getAvailableCommandsSpy.mockResolvedValue([]);
|
||||
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
describe('setMode', () => {
|
||||
it.each([
|
||||
['plan', ApprovalMode.PLAN],
|
||||
['default', ApprovalMode.DEFAULT],
|
||||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const result = await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' qwen3-coder-plus ',
|
||||
});
|
||||
|
||||
expect(mockConfig.setModel).toHaveBeenCalledWith('qwen3-coder-plus', {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({ modelId: 'qwen3-coder-plus' });
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ' ',
|
||||
}),
|
||||
).rejects.toThrow('Invalid params');
|
||||
|
||||
expect(mockConfig.setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors from config.setModel', async () => {
|
||||
const configError = new Error('Invalid model');
|
||||
setModelSpy.mockRejectedValueOnce(configError);
|
||||
|
||||
await expect(
|
||||
session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'invalid-model',
|
||||
}),
|
||||
).rejects.toThrow('Invalid model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendAvailableCommandsUpdate', () => {
|
||||
it('sends available_commands_update from getAvailableCommands()', async () => {
|
||||
getAvailableCommandsSpy.mockResolvedValueOnce([
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
},
|
||||
]);
|
||||
|
||||
await session.sendAvailableCommandsUpdate();
|
||||
|
||||
expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'test-session-id',
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize project context',
|
||||
input: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows errors and does not throw', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
getAvailableCommandsSpy.mockRejectedValueOnce(
|
||||
new Error('Command discovery failed'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
session.sendAvailableCommandsUpdate(),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mockClient.sessionUpdate).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
@@ -348,6 +350,31 @@ export class Session implements SessionContext {
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
const modelId = params.modelId.trim();
|
||||
|
||||
if (!modelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
}
|
||||
|
||||
// Attempt to set the model using config
|
||||
await this.config.setModel(modelId, {
|
||||
reason: 'user_request_acp',
|
||||
context: 'session/set_model',
|
||||
});
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
|
||||
return {
|
||||
modelId: currentModel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a current_mode_update notification to the client.
|
||||
* Called after the agent switches modes (e.g., from exit_plan_mode tool).
|
||||
|
||||
@@ -332,7 +332,14 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
.option('experimental-skills', {
|
||||
type: 'boolean',
|
||||
description: 'Enable experimental Skills feature',
|
||||
default: settings.tools?.experimental?.skills ?? false,
|
||||
default: (() => {
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
return settings.experimental?.skills ?? legacySkills ?? false;
|
||||
})(),
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
|
||||
@@ -122,9 +122,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
|
||||
// Auto-completion
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
// Completion navigation (arrow or Ctrl+P/N)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
|
||||
// Completion navigation uses only arrow keys
|
||||
// Ctrl+P/N are reserved for history navigation (HISTORY_UP/DOWN)
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }],
|
||||
|
||||
// Text input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
|
||||
@@ -921,6 +921,21 @@ export function migrateDeprecatedSettings(
|
||||
|
||||
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
|
||||
}
|
||||
|
||||
const legacySkills = (
|
||||
settings as Settings & {
|
||||
tools?: { experimental?: { skills?: boolean } };
|
||||
}
|
||||
).tools?.experimental?.skills;
|
||||
if (
|
||||
legacySkills !== undefined &&
|
||||
settings.experimental?.skills === undefined
|
||||
) {
|
||||
console.log(
|
||||
`Migrating deprecated tools.experimental.skills setting from ${scope} settings...`,
|
||||
);
|
||||
loadedSettings.setValue(scope, 'experimental.skills', legacySkills);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
|
||||
@@ -434,6 +434,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Show welcome back dialog when returning to a project with conversation history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableUserFeedback: {
|
||||
type: 'boolean',
|
||||
label: 'Enable User Feedback',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Show optional feedback dialog after conversations to help improve Qwen performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
label: 'Accessibility',
|
||||
@@ -464,6 +474,15 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
feedbackLastShownTimestamp: {
|
||||
type: 'number',
|
||||
label: 'Feedback Last Shown Timestamp',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 0,
|
||||
description: 'The last time the feedback dialog was shown.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -690,18 +709,6 @@ const SETTINGS_SCHEMA = {
|
||||
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
|
||||
],
|
||||
},
|
||||
contextWindowSize: {
|
||||
type: 'number',
|
||||
label: 'Context Window Size',
|
||||
category: 'Generation Configuration',
|
||||
requiresRestart: false,
|
||||
default: -1,
|
||||
description:
|
||||
'Override the automatic context window size detection. Set to -1 to use automatic detection based on the model. Set to a positive number to use a custom context window size.',
|
||||
parentKey: 'generationConfig',
|
||||
childKey: 'contextWindowSize',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -984,27 +991,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
experimental: {
|
||||
type: 'object',
|
||||
label: 'Experimental',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description: 'Experimental tool features.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Skills',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1231,6 +1217,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Skills',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental Agent Skills feature. When enabled, Qwen Code can use Skills from .qwen/skills/ and ~/.qwen/skills/.',
|
||||
showInDialog: true,
|
||||
},
|
||||
extensionManagement: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Management',
|
||||
|
||||
@@ -289,6 +289,13 @@ export default {
|
||||
'Show Citations': 'Quellenangaben anzeigen',
|
||||
'Custom Witty Phrases': 'Benutzerdefinierte Witzige Sprüche',
|
||||
'Enable Welcome Back': 'Willkommen-zurück aktivieren',
|
||||
'Enable User Feedback': 'Benutzerfeedback aktivieren',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Wie macht sich Qwen in dieser Sitzung? (optional)',
|
||||
Bad: 'Schlecht',
|
||||
Good: 'Gut',
|
||||
'Not Sure Yet': 'Noch nicht sicher',
|
||||
'Any other key': 'Beliebige andere Taste',
|
||||
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
|
||||
'Screen Reader Mode': 'Bildschirmleser-Modus',
|
||||
'IDE Mode': 'IDE-Modus',
|
||||
|
||||
@@ -286,6 +286,13 @@ export default {
|
||||
'Show Citations': 'Show Citations',
|
||||
'Custom Witty Phrases': 'Custom Witty Phrases',
|
||||
'Enable Welcome Back': 'Enable Welcome Back',
|
||||
'Enable User Feedback': 'Enable User Feedback',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'How is Qwen doing this session? (optional)',
|
||||
Bad: 'Bad',
|
||||
Good: 'Good',
|
||||
'Not Sure Yet': 'Not Sure Yet',
|
||||
'Any other key': 'Any other key',
|
||||
'Disable Loading Phrases': 'Disable Loading Phrases',
|
||||
'Screen Reader Mode': 'Screen Reader Mode',
|
||||
'IDE Mode': 'IDE Mode',
|
||||
|
||||
@@ -289,6 +289,13 @@ export default {
|
||||
'Show Citations': 'Показывать цитаты',
|
||||
'Custom Witty Phrases': 'Пользовательские остроумные фразы',
|
||||
'Enable Welcome Back': 'Включить приветствие при возврате',
|
||||
'Enable User Feedback': 'Включить отзывы пользователей',
|
||||
'How is Qwen doing this session? (optional)':
|
||||
'Как дела у Qwen в этой сессии? (необязательно)',
|
||||
Bad: 'Плохо',
|
||||
Good: 'Хорошо',
|
||||
'Not Sure Yet': 'Пока не уверен',
|
||||
'Any other key': 'Любая другая клавиша',
|
||||
'Disable Loading Phrases': 'Отключить фразы при загрузке',
|
||||
'Screen Reader Mode': 'Режим программы чтения с экрана',
|
||||
'IDE Mode': 'Режим IDE',
|
||||
|
||||
@@ -277,6 +277,12 @@ export default {
|
||||
'Show Citations': '显示引用',
|
||||
'Custom Witty Phrases': '自定义诙谐短语',
|
||||
'Enable Welcome Back': '启用欢迎回来',
|
||||
'Enable User Feedback': '启用用户反馈',
|
||||
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
|
||||
Bad: '不满意',
|
||||
Good: '满意',
|
||||
'Not Sure Yet': '暂不评价',
|
||||
'Any other key': '任意其他键',
|
||||
'Disable Loading Phrases': '禁用加载短语',
|
||||
'Screen Reader Mode': '屏幕阅读器模式',
|
||||
'IDE Mode': 'IDE 模式',
|
||||
|
||||
@@ -45,6 +45,7 @@ import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useFeedbackDialog } from './hooks/useFeedbackDialog.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
@@ -1195,6 +1196,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
} = useFeedbackDialog({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history: historyManager.history,
|
||||
sessionStats,
|
||||
});
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
@@ -1291,6 +1305,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
@@ -1381,6 +1397,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1421,6 +1439,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -1456,6 +1478,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
handleResume,
|
||||
// Feedback dialog
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
61
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
61
packages/cli/src/ui/FeedbackDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useUIActions } from './contexts/UIActionsContext.js';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
|
||||
const FEEDBACK_OPTIONS = {
|
||||
GOOD: 1,
|
||||
BAD: 2,
|
||||
NOT_SURE: 3,
|
||||
} as const;
|
||||
|
||||
const FEEDBACK_OPTION_KEYS = {
|
||||
[FEEDBACK_OPTIONS.GOOD]: '1',
|
||||
[FEEDBACK_OPTIONS.BAD]: '2',
|
||||
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
|
||||
} as const;
|
||||
|
||||
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
|
||||
|
||||
export const FeedbackDialog: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
|
||||
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
|
||||
} else {
|
||||
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
|
||||
}
|
||||
|
||||
uiActions.closeFeedbackDialog();
|
||||
},
|
||||
{ isActive: uiState.isFeedbackDialogOpen },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Box>
|
||||
<Text color="cyan">● </Text>
|
||||
<Text bold>{t('How is Qwen doing this session? (optional)')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">
|
||||
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]}:{' '}
|
||||
</Text>
|
||||
<Text>{t('Good')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
|
||||
<Text>{t('Bad')}</Text>
|
||||
<Text> </Text>
|
||||
<Text color="cyan">{t('Any other key')}: </Text>
|
||||
<Text>{t('Not Sure Yet')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Config,
|
||||
ContentGeneratorConfig,
|
||||
ModelProvidersConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
@@ -214,11 +218,19 @@ export const useAuthCommand = (
|
||||
|
||||
if (authType === AuthType.USE_OPENAI) {
|
||||
if (credentials) {
|
||||
config.updateCredentials({
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
});
|
||||
// Pass settings.model.generationConfig to updateCredentials so it can be merged
|
||||
// after clearing provider-sourced config. This ensures settings.json generationConfig
|
||||
// fields (e.g., samplingParams, timeout) are preserved.
|
||||
const settingsGenerationConfig = settings.merged.model
|
||||
?.generationConfig as Partial<ContentGeneratorConfig> | undefined;
|
||||
config.updateCredentials(
|
||||
{
|
||||
apiKey: credentials.apiKey,
|
||||
baseUrl: credentials.baseUrl,
|
||||
model: credentials.model,
|
||||
},
|
||||
settingsGenerationConfig,
|
||||
);
|
||||
await performAuth(authType, credentials);
|
||||
}
|
||||
return;
|
||||
@@ -226,7 +238,13 @@ export const useAuthCommand = (
|
||||
|
||||
await performAuth(authType);
|
||||
},
|
||||
[config, performAuth, isProviderManagedModel, onAuthError],
|
||||
[
|
||||
config,
|
||||
performAuth,
|
||||
isProviderManagedModel,
|
||||
onAuthError,
|
||||
settings.merged.model?.generationConfig,
|
||||
],
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { FeedbackDialog } from '../FeedbackDialog.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Composer = () => {
|
||||
@@ -134,6 +135,8 @@ export const Composer = () => {
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{uiState.isFeedbackDialogOpen && <FeedbackDialog />}
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={uiState.buffer}
|
||||
|
||||
@@ -6,22 +6,18 @@
|
||||
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { tokenLimit, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { tokenLimit } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const ContextUsageDisplay = ({
|
||||
promptTokenCount,
|
||||
model,
|
||||
terminalWidth,
|
||||
config,
|
||||
}: {
|
||||
promptTokenCount: number;
|
||||
model: string;
|
||||
terminalWidth: number;
|
||||
config: Config;
|
||||
}) => {
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
const contextLimit = tokenLimit(model, 'input', contentGeneratorConfig);
|
||||
const percentage = promptTokenCount / contextLimit;
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
|
||||
@@ -43,7 +43,6 @@ const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => defaultProps.model),
|
||||
getTargetDir: vi.fn(() => defaultProps.targetDir),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getContentGeneratorConfig: vi.fn(() => ({})),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ export const Footer: React.FC = () => {
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
config={config}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
|
||||
@@ -33,6 +33,9 @@ vi.mock('../hooks/useCommandCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
|
||||
}));
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
@@ -278,7 +281,7 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
it('should call completion.navigateUp for up arrow when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -293,19 +296,22 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test up arrow
|
||||
// Test up arrow for completion navigation
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+P should navigate history, not completion
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
it('should call completion.navigateDown for down arrow when suggestions are showing', async () => {
|
||||
mockedUseCommandCompletion.mockReturnValue({
|
||||
...mockCommandCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -319,14 +325,17 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test down arrow
|
||||
// Test down arrow for completion navigation
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
// Ctrl+N should navigate history, not completion
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1);
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -764,6 +773,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -791,6 +802,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -818,6 +831,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -845,6 +860,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -872,6 +889,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -900,6 +919,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -927,6 +948,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -955,6 +978,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -983,6 +1008,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1011,6 +1038,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1039,6 +1068,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1069,6 +1100,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1097,6 +1130,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
@@ -1127,6 +1162,8 @@ describe('InputPrompt', () => {
|
||||
mockCommandContext,
|
||||
false,
|
||||
expect.any(Object),
|
||||
// active parameter: completion enabled when not just navigated history
|
||||
true,
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -100,6 +102,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
isEmbeddedShellFocused,
|
||||
}) => {
|
||||
const isShellFocused = useShellFocusState();
|
||||
const uiState = useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -135,6 +138,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandContext,
|
||||
reverseSearchActive,
|
||||
config,
|
||||
// Suppress completion when history navigation just occurred
|
||||
!justNavigatedHistory,
|
||||
);
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
@@ -219,9 +224,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
// History navigation (Ctrl+P/N) now always works since completion navigation
|
||||
// only uses arrow keys. Only disable in shell mode.
|
||||
isActive: !shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
@@ -326,6 +331,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept feedback dialog option keys (1, 2) when dialog is open
|
||||
if (
|
||||
uiState.isFeedbackDialogOpen &&
|
||||
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
if (key.name !== 'escape') {
|
||||
if (escPressCount > 0 || showEscapePrompt) {
|
||||
@@ -670,6 +683,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
persistModelSelection(settings, effectiveModelId);
|
||||
persistAuthTypeSelection(settings, effectiveAuthType);
|
||||
|
||||
const baseUrl = after?.baseUrl ?? '(default)';
|
||||
const baseUrl = after?.baseUrl ?? t('(default)');
|
||||
const maskedKey = maskApiKey(after?.apiKey);
|
||||
uiState?.historyManager.addItem(
|
||||
{
|
||||
@@ -322,7 +322,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
<>
|
||||
<ConfigRow
|
||||
label="Base URL"
|
||||
value={effectiveConfig?.baseUrl ?? ''}
|
||||
value={effectiveConfig?.baseUrl ?? t('(default)')}
|
||||
badge={formatSourceBadge(sources['baseUrl'])}
|
||||
/>
|
||||
<ConfigRow
|
||||
|
||||
@@ -66,6 +66,10 @@ export interface UIActions {
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
handleResume: (sessionId: string) => void;
|
||||
// Feedback dialog
|
||||
openFeedbackDialog: () => void;
|
||||
closeFeedbackDialog: () => void;
|
||||
submitFeedback: (rating: number) => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface UIState {
|
||||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
@@ -45,6 +45,8 @@ export function useCommandCompletion(
|
||||
commandContext: CommandContext,
|
||||
reverseSearchActive: boolean = false,
|
||||
config?: Config,
|
||||
// When false, suppresses showing suggestions (e.g., after history navigation)
|
||||
active: boolean = true,
|
||||
): UseCommandCompletionReturn {
|
||||
const {
|
||||
suggestions,
|
||||
@@ -152,7 +154,11 @@ export function useCommandCompletion(
|
||||
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
|
||||
if (
|
||||
completionMode === CompletionMode.IDLE ||
|
||||
reverseSearchActive ||
|
||||
!active
|
||||
) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
@@ -163,6 +169,7 @@ export function useCommandCompletion(
|
||||
suggestions.length,
|
||||
isLoadingSuggestions,
|
||||
reverseSearchActive,
|
||||
active,
|
||||
resetCompletionState,
|
||||
setShowSuggestions,
|
||||
]);
|
||||
|
||||
178
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
178
packages/cli/src/ui/hooks/useFeedbackDialog.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
type Config,
|
||||
logUserFeedback,
|
||||
UserFeedbackEvent,
|
||||
type UserFeedbackRating,
|
||||
isNodeError,
|
||||
AuthType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState, MessageType, type HistoryItem } from '../types.js';
|
||||
import {
|
||||
SettingScope,
|
||||
type LoadedSettings,
|
||||
USER_SETTINGS_PATH,
|
||||
} from '../../config/settings.js';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
|
||||
const MIN_TOOL_CALLS = 10; // Minimum tool calls to show feedback dialog
|
||||
const MIN_USER_MESSAGES = 5; // Minimum user messages to show feedback dialog
|
||||
|
||||
// Fatigue mechanism constants
|
||||
const FEEDBACK_COOLDOWN_HOURS = 24; // Hours to wait before showing feedback dialog again
|
||||
|
||||
/**
|
||||
* Check if the last message in the conversation history is an AI response
|
||||
*/
|
||||
const lastMessageIsAIResponse = (history: HistoryItem[]): boolean =>
|
||||
history.length > 0 && history[history.length - 1].type === MessageType.GEMINI;
|
||||
|
||||
/**
|
||||
* Read feedbackLastShownTimestamp directly from the user settings file
|
||||
*/
|
||||
const getFeedbackLastShownTimestampFromFile = (): number => {
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
const content = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||
const settings = JSON.parse(stripJsonComments(content));
|
||||
return settings?.ui?.feedbackLastShownTimestamp ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
console.warn(
|
||||
'Failed to read feedbackLastShownTimestamp from settings file:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we should show the feedback dialog based on fatigue mechanism
|
||||
*/
|
||||
const shouldShowFeedbackBasedOnFatigue = (): boolean => {
|
||||
const feedbackLastShownTimestamp = getFeedbackLastShownTimestampFromFile();
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastShown = now - feedbackLastShownTimestamp;
|
||||
const cooldownMs = FEEDBACK_COOLDOWN_HOURS * 60 * 60 * 1000;
|
||||
|
||||
return timeSinceLastShown >= cooldownMs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the session meets the minimum requirements for showing feedback
|
||||
* Either tool calls > 10 OR user messages > 5
|
||||
*/
|
||||
const meetsMinimumSessionRequirements = (
|
||||
sessionStats: SessionStatsState,
|
||||
): boolean => {
|
||||
const toolCallsCount = sessionStats.metrics.tools.totalCalls;
|
||||
const userMessagesCount = sessionStats.promptCount;
|
||||
|
||||
return (
|
||||
toolCallsCount > MIN_TOOL_CALLS || userMessagesCount > MIN_USER_MESSAGES
|
||||
);
|
||||
};
|
||||
|
||||
export interface UseFeedbackDialogProps {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
streamingState: StreamingState;
|
||||
history: HistoryItem[];
|
||||
sessionStats: SessionStatsState;
|
||||
}
|
||||
|
||||
export const useFeedbackDialog = ({
|
||||
config,
|
||||
settings,
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
}: UseFeedbackDialogProps) => {
|
||||
// Feedback dialog state
|
||||
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
|
||||
|
||||
const openFeedbackDialog = useCallback(() => {
|
||||
setIsFeedbackDialogOpen(true);
|
||||
|
||||
// Record the timestamp when feedback dialog is shown (fire and forget)
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'ui.feedbackLastShownTimestamp',
|
||||
Date.now(),
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const closeFeedbackDialog = useCallback(
|
||||
() => setIsFeedbackDialogOpen(false),
|
||||
[],
|
||||
);
|
||||
|
||||
const submitFeedback = useCallback(
|
||||
(rating: number) => {
|
||||
// Create and log the feedback event
|
||||
const feedbackEvent = new UserFeedbackEvent(
|
||||
sessionStats.sessionId,
|
||||
rating as UserFeedbackRating,
|
||||
config.getModel(),
|
||||
config.getApprovalMode(),
|
||||
);
|
||||
|
||||
logUserFeedback(config, feedbackEvent);
|
||||
closeFeedbackDialog();
|
||||
},
|
||||
[config, sessionStats, closeFeedbackDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAndShowFeedback = () => {
|
||||
if (streamingState === StreamingState.Idle && history.length > 0) {
|
||||
// Show feedback dialog if:
|
||||
// 1. User is authenticated via QWEN_OAUTH
|
||||
// 2. Qwen logger is enabled (required for feedback submission)
|
||||
// 3. User feedback is enabled in settings
|
||||
// 4. The last message is an AI response
|
||||
// 5. Random chance (25% probability)
|
||||
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
|
||||
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
|
||||
if (
|
||||
config.getAuthType() !== AuthType.QWEN_OAUTH ||
|
||||
!config.getUsageStatisticsEnabled() ||
|
||||
settings.merged.ui?.enableUserFeedback === false ||
|
||||
!lastMessageIsAIResponse(history) ||
|
||||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
|
||||
!meetsMinimumSessionRequirements(sessionStats)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check fatigue mechanism (synchronous)
|
||||
if (shouldShowFeedbackBasedOnFatigue()) {
|
||||
openFeedbackDialog();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAndShowFeedback();
|
||||
}, [
|
||||
streamingState,
|
||||
history,
|
||||
sessionStats,
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
settings.merged.ui?.enableUserFeedback,
|
||||
config,
|
||||
]);
|
||||
|
||||
return {
|
||||
isFeedbackDialogOpen,
|
||||
openFeedbackDialog,
|
||||
closeFeedbackDialog,
|
||||
submitFeedback,
|
||||
};
|
||||
};
|
||||
@@ -38,10 +38,10 @@ describe('keyMatchers', () => {
|
||||
[Command.NAVIGATION_DOWN]: (key: Key) => key.name === 'down',
|
||||
[Command.ACCEPT_SUGGESTION]: (key: Key) =>
|
||||
key.name === 'tab' || (key.name === 'return' && !key.ctrl),
|
||||
[Command.COMPLETION_UP]: (key: Key) =>
|
||||
key.name === 'up' || (key.ctrl && key.name === 'p'),
|
||||
[Command.COMPLETION_DOWN]: (key: Key) =>
|
||||
key.name === 'down' || (key.ctrl && key.name === 'n'),
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
[Command.COMPLETION_UP]: (key: Key) => key.name === 'up',
|
||||
[Command.COMPLETION_DOWN]: (key: Key) => key.name === 'down',
|
||||
[Command.ESCAPE]: (key: Key) => key.name === 'escape',
|
||||
[Command.SUBMIT]: (key: Key) =>
|
||||
key.name === 'return' && !key.ctrl && !key.meta && !key.paste,
|
||||
@@ -164,14 +164,26 @@ describe('keyMatchers', () => {
|
||||
negative: [createKey('return', { ctrl: true }), createKey('space')],
|
||||
},
|
||||
{
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
command: Command.COMPLETION_UP,
|
||||
positive: [createKey('up'), createKey('p', { ctrl: true })],
|
||||
negative: [createKey('p'), createKey('down')],
|
||||
positive: [createKey('up')],
|
||||
negative: [
|
||||
createKey('p'),
|
||||
createKey('down'),
|
||||
createKey('p', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
// Completion navigation only uses arrow keys (not Ctrl+P/N)
|
||||
// to allow Ctrl+P/N to always navigate history
|
||||
command: Command.COMPLETION_DOWN,
|
||||
positive: [createKey('down'), createKey('n', { ctrl: true })],
|
||||
negative: [createKey('n'), createKey('up')],
|
||||
positive: [createKey('down')],
|
||||
negative: [
|
||||
createKey('n'),
|
||||
createKey('up'),
|
||||
createKey('n', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Text input
|
||||
|
||||
721
packages/cli/src/utils/modelConfigUtils.test.ts
Normal file
721
packages/cli/src/utils/modelConfigUtils.test.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
AuthType,
|
||||
resolveModelConfig,
|
||||
type ProviderModelConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getAuthTypeFromEnv,
|
||||
resolveCliGenerationConfig,
|
||||
} from './modelConfigUtils.js';
|
||||
import type { Settings } from '../config/settings.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
resolveModelConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('modelConfigUtils', () => {
|
||||
describe('getAuthTypeFromEnv', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should return USE_OPENAI when all OpenAI env vars are set', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_OPENAI);
|
||||
});
|
||||
|
||||
it('should return undefined when OpenAI env vars are incomplete', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
// Missing OPENAI_BASE_URL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return QWEN_OAUTH when QWEN_OAUTH is set', () => {
|
||||
process.env['QWEN_OAUTH'] = 'true';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
||||
});
|
||||
|
||||
it('should return USE_GEMINI when Gemini env vars are set', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
process.env['GEMINI_MODEL'] = 'gemini-pro';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('should return undefined when Gemini env vars are incomplete', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Missing GEMINI_MODEL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return USE_VERTEX_AI when Google env vars are set', () => {
|
||||
process.env['GOOGLE_API_KEY'] = 'test-key';
|
||||
process.env['GOOGLE_MODEL'] = 'vertex-model';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_VERTEX_AI);
|
||||
});
|
||||
|
||||
it('should return undefined when Google env vars are incomplete', () => {
|
||||
process.env['GOOGLE_API_KEY'] = 'test-key';
|
||||
// Missing GOOGLE_MODEL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return USE_ANTHROPIC when Anthropic env vars are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
||||
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
||||
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.USE_ANTHROPIC);
|
||||
});
|
||||
|
||||
it('should return undefined when Anthropic env vars are incomplete', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-key';
|
||||
process.env['ANTHROPIC_MODEL'] = 'claude-3';
|
||||
// Missing ANTHROPIC_BASE_URL
|
||||
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize QWEN_OAUTH over other auth types when explicitly set', () => {
|
||||
process.env['QWEN_OAUTH'] = 'true';
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
process.env['OPENAI_MODEL'] = 'gpt-4';
|
||||
process.env['OPENAI_BASE_URL'] = 'https://api.openai.com';
|
||||
|
||||
// QWEN_OAUTH is checked first, so it should be returned even when other auth vars are set
|
||||
expect(getAuthTypeFromEnv()).toBe(AuthType.QWEN_OAUTH);
|
||||
});
|
||||
|
||||
it('should return undefined when no auth env vars are set', () => {
|
||||
expect(getAuthTypeFromEnv()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCliGenerationConfig', () => {
|
||||
const originalEnv = process.env;
|
||||
const originalConsoleWarn = console.warn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
console.warn = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
console.warn = originalConsoleWarn;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeMockSettings(overrides?: Partial<Settings>): Settings {
|
||||
return {
|
||||
model: { name: 'default-model' },
|
||||
security: {
|
||||
auth: {
|
||||
apiKey: 'settings-api-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as Settings;
|
||||
}
|
||||
|
||||
it('should resolve config from argv with highest precedence', () => {
|
||||
const argv = {
|
||||
model: 'argv-model',
|
||||
openaiApiKey: 'argv-key',
|
||||
openaiBaseUrl: 'https://argv.example.com',
|
||||
};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'argv-model',
|
||||
apiKey: 'argv-key',
|
||||
baseUrl: 'https://argv.example.com',
|
||||
},
|
||||
sources: {
|
||||
model: { kind: 'cli', detail: '--model' },
|
||||
apiKey: { kind: 'cli', detail: '--openaiApiKey' },
|
||||
baseUrl: { kind: 'cli', detail: '--openaiBaseUrl' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('argv-model');
|
||||
expect(result.apiKey).toBe('argv-key');
|
||||
expect(result.baseUrl).toBe('https://argv.example.com');
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cli: {
|
||||
model: 'argv-model',
|
||||
apiKey: 'argv-key',
|
||||
baseUrl: 'https://argv.example.com',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve config from settings when argv is not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: { name: 'settings-model' },
|
||||
security: {
|
||||
auth: {
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'settings-model',
|
||||
apiKey: 'settings-key',
|
||||
baseUrl: 'https://settings.example.com',
|
||||
},
|
||||
sources: {
|
||||
model: { kind: 'settings', detail: 'model.name' },
|
||||
apiKey: { kind: 'settings', detail: 'security.auth.apiKey' },
|
||||
baseUrl: { kind: 'settings', detail: 'security.auth.baseUrl' },
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('settings-model');
|
||||
expect(result.apiKey).toBe('settings-key');
|
||||
expect(result.baseUrl).toBe('https://settings.example.com');
|
||||
});
|
||||
|
||||
it('should merge generationConfig from settings', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
generationConfig: {
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
timeout: 5000,
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
samplingParams: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.samplingParams?.temperature).toBe(0.7);
|
||||
expect(result.generationConfig.samplingParams?.max_tokens).toBe(1000);
|
||||
expect(result.generationConfig.timeout).toBe(5000);
|
||||
});
|
||||
|
||||
it('should resolve OpenAI logging from argv', () => {
|
||||
const argv = {
|
||||
openaiLogging: true,
|
||||
openaiLoggingDir: '/custom/log/dir',
|
||||
};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
||||
expect(result.generationConfig.openAILoggingDir).toBe('/custom/log/dir');
|
||||
});
|
||||
|
||||
it('should resolve OpenAI logging from settings when argv is undefined', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: '/settings/log/dir',
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(true);
|
||||
expect(result.generationConfig.openAILoggingDir).toBe(
|
||||
'/settings/log/dir',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default OpenAI logging to false when not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig.enableOpenAILogging).toBe(false);
|
||||
});
|
||||
|
||||
it('should find modelProvider from settings when authType and model match', () => {
|
||||
const argv = { model: 'provider-model' };
|
||||
const modelProvider: ProviderModelConfig = {
|
||||
id: 'provider-model',
|
||||
name: 'Provider Model',
|
||||
generationConfig: {
|
||||
samplingParams: { temperature: 0.8 },
|
||||
},
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [modelProvider],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'provider-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find modelProvider from settings.model.name when argv.model is not provided', () => {
|
||||
const argv = {};
|
||||
const modelProvider: ProviderModelConfig = {
|
||||
id: 'settings-model',
|
||||
name: 'Settings Model',
|
||||
generationConfig: {
|
||||
samplingParams: { temperature: 0.9 },
|
||||
},
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
model: { name: 'settings-model' },
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [modelProvider],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'settings-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not find modelProvider when authType is undefined', () => {
|
||||
const argv = { model: 'test-model' };
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: [{ id: 'test-model', name: 'Test Model' }],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = undefined;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not find modelProvider when modelProviders is not an array', () => {
|
||||
const argv = { model: 'test-model' };
|
||||
const settings = makeMockSettings({
|
||||
modelProviders: {
|
||||
[AuthType.USE_OPENAI]: null as unknown as ProviderModelConfig[],
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should log warnings from resolveModelConfig', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: ['Warning 1', 'Warning 2'],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('Warning 1');
|
||||
expect(console.warn).toHaveBeenCalledWith('Warning 2');
|
||||
});
|
||||
|
||||
it('should use custom env when provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
const customEnv = {
|
||||
OPENAI_API_KEY: 'custom-key',
|
||||
OPENAI_MODEL: 'custom-model',
|
||||
};
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'custom-model',
|
||||
apiKey: 'custom-key',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
env: customEnv,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: customEnv,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use process.env when env is not provided', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: process.env,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty strings for missing model, apiKey, and baseUrl', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings();
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('');
|
||||
expect(result.apiKey).toBe('');
|
||||
expect(result.baseUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should merge resolved config with logging settings', () => {
|
||||
const argv = {
|
||||
openaiLogging: true,
|
||||
};
|
||||
const settings = makeMockSettings({
|
||||
model: {
|
||||
name: 'test-model',
|
||||
generationConfig: {
|
||||
timeout: 5000,
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: 'test-model',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://test.com',
|
||||
samplingParams: { temperature: 0.5 },
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.generationConfig).toEqual({
|
||||
model: 'test-model',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://test.com',
|
||||
samplingParams: { temperature: 0.5 },
|
||||
enableOpenAILogging: true,
|
||||
openAILoggingDir: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle settings without model property', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
model: undefined as unknown as Settings['model'],
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const result = resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(result.model).toBe('');
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
model: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings without security.auth property', () => {
|
||||
const argv = {};
|
||||
const settings = makeMockSettings({
|
||||
security: undefined,
|
||||
});
|
||||
const selectedAuthType = AuthType.USE_OPENAI;
|
||||
|
||||
vi.mocked(resolveModelConfig).mockReturnValue({
|
||||
config: {
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
sources: {},
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
resolveCliGenerationConfig({
|
||||
argv,
|
||||
settings,
|
||||
selectedAuthType,
|
||||
});
|
||||
|
||||
expect(vi.mocked(resolveModelConfig)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,20 +44,31 @@ export interface ResolvedCliGenerationConfig {
|
||||
}
|
||||
|
||||
export function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
if (process.env['OPENAI_API_KEY']) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
if (process.env['QWEN_OAUTH']) {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
if (
|
||||
process.env['OPENAI_API_KEY'] &&
|
||||
process.env['OPENAI_MODEL'] &&
|
||||
process.env['OPENAI_BASE_URL']
|
||||
) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
|
||||
if (process.env['GEMINI_API_KEY'] && process.env['GEMINI_MODEL']) {
|
||||
return AuthType.USE_GEMINI;
|
||||
}
|
||||
if (process.env['GOOGLE_API_KEY']) {
|
||||
|
||||
if (process.env['GOOGLE_API_KEY'] && process.env['GOOGLE_MODEL']) {
|
||||
return AuthType.USE_VERTEX_AI;
|
||||
}
|
||||
if (process.env['ANTHROPIC_API_KEY']) {
|
||||
|
||||
if (
|
||||
process.env['ANTHROPIC_API_KEY'] &&
|
||||
process.env['ANTHROPIC_MODEL'] &&
|
||||
process.env['ANTHROPIC_BASE_URL']
|
||||
) {
|
||||
return AuthType.USE_ANTHROPIC;
|
||||
}
|
||||
|
||||
|
||||
@@ -708,12 +708,15 @@ export class Config {
|
||||
* Exclusive for `OpenAIKeyPrompt` to update credentials via `/auth`
|
||||
* Delegates to ModelsConfig.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
this._modelsConfig.updateCredentials(credentials);
|
||||
updateCredentials(
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
this._modelsConfig.updateCredentials(credentials, settingsGenerationConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,9 +91,6 @@ export type ContentGeneratorConfig = {
|
||||
userAgent?: string;
|
||||
// Schema compliance mode for tool definitions
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
// Context window size override. If set to a positive number, it will override
|
||||
// the automatic detection. Set to -1 to use automatic detection.
|
||||
contextWindowSize?: number;
|
||||
// Custom HTTP headers to be sent with requests
|
||||
customHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -224,29 +224,14 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [
|
||||
* or output generation based on the model and token type. It uses the same
|
||||
* normalization logic for consistency across both input and output limits.
|
||||
*
|
||||
* If a contentGeneratorConfig is provided with a contextWindowSize > 0, that value
|
||||
* will be used for input token limits instead of the automatic detection.
|
||||
*
|
||||
* @param model - The model name to get the token limit for
|
||||
* @param type - The type of token limit ('input' for context window, 'output' for generation)
|
||||
* @param contentGeneratorConfig - Optional config that may contain a contextWindowSize override
|
||||
* @returns The maximum number of tokens allowed for this model and type
|
||||
*/
|
||||
export function tokenLimit(
|
||||
model: Model,
|
||||
type: TokenLimitType = 'input',
|
||||
contentGeneratorConfig?: { contextWindowSize?: number },
|
||||
): TokenCount {
|
||||
// If user configured a specific context window size for input, use it
|
||||
const configuredLimit = contentGeneratorConfig?.contextWindowSize;
|
||||
if (
|
||||
type === 'input' &&
|
||||
configuredLimit !== undefined &&
|
||||
configuredLimit > 0
|
||||
) {
|
||||
return configuredLimit;
|
||||
}
|
||||
|
||||
const norm = normalize(model);
|
||||
|
||||
// Choose the appropriate patterns based on token type
|
||||
|
||||
@@ -25,7 +25,6 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [
|
||||
'disableCacheControl',
|
||||
'schemaCompliance',
|
||||
'reasoning',
|
||||
'contextWindowSize',
|
||||
'customHeaders',
|
||||
] as const satisfies ReadonlyArray<keyof ContentGeneratorConfig>;
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('ModelsConfig', () => {
|
||||
expect(gc.apiKeyEnvKey).toBe('API_KEY_SHARED');
|
||||
});
|
||||
|
||||
it('should preserve settings generationConfig when model is updated via updateCredentials even if it matches modelProviders', () => {
|
||||
it('should use provider config when modelId exists in registry even after updateCredentials', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
@@ -213,7 +213,7 @@ describe('ModelsConfig', () => {
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
||||
timeout: 9999,
|
||||
maxRetries: 9,
|
||||
@@ -235,30 +235,30 @@ describe('ModelsConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// User manually updates the model via updateCredentials (e.g. key prompt flow).
|
||||
// Even if the model ID matches a modelProviders entry, we must not apply provider defaults
|
||||
// that would overwrite settings.model.generationConfig.
|
||||
modelsConfig.updateCredentials({ model: 'model-a' });
|
||||
// User manually updates credentials via updateCredentials.
|
||||
// Note: In practice, handleAuthSelect prevents using a modelId that matches a provider model,
|
||||
// but if syncAfterAuthRefresh is called with a modelId that exists in registry,
|
||||
// we should use provider config.
|
||||
modelsConfig.updateCredentials({ apiKey: 'manual-key' });
|
||||
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
// syncAfterAuthRefresh with a modelId that exists in registry should use provider config
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'model-a');
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('model-a');
|
||||
expect(gc.samplingParams?.temperature).toBe(0.9);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(999);
|
||||
expect(gc.timeout).toBe(9999);
|
||||
expect(gc.maxRetries).toBe(9);
|
||||
// Provider config should be applied
|
||||
expect(gc.samplingParams?.temperature).toBe(0.1);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(123);
|
||||
expect(gc.timeout).toBe(111);
|
||||
expect(gc.maxRetries).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve settings generationConfig across multiple auth refreshes after updateCredentials', () => {
|
||||
it('should preserve settings generationConfig when modelId does not exist in registry', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'model-a',
|
||||
name: 'Model A',
|
||||
id: 'provider-model',
|
||||
name: 'Provider Model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
envKey: 'API_KEY_A',
|
||||
generationConfig: {
|
||||
@@ -270,11 +270,12 @@ describe('ModelsConfig', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// Simulate settings with a custom model (not in registry)
|
||||
const modelsConfig = new ModelsConfig({
|
||||
initialAuthType: AuthType.USE_OPENAI,
|
||||
modelProvidersConfig,
|
||||
generationConfig: {
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
samplingParams: { temperature: 0.9, max_tokens: 999 },
|
||||
timeout: 9999,
|
||||
maxRetries: 9,
|
||||
@@ -296,25 +297,21 @@ describe('ModelsConfig', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// User manually sets credentials for a custom model (not in registry)
|
||||
modelsConfig.updateCredentials({
|
||||
apiKey: 'manual-key',
|
||||
baseUrl: 'https://manual.example.com/v1',
|
||||
model: 'model-a',
|
||||
model: 'custom-model',
|
||||
});
|
||||
|
||||
// First auth refresh
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
// First auth refresh - modelId doesn't exist in registry, so credentials should be preserved
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
||||
// Second auth refresh should still preserve settings generationConfig
|
||||
modelsConfig.syncAfterAuthRefresh(
|
||||
AuthType.USE_OPENAI,
|
||||
modelsConfig.getModel(),
|
||||
);
|
||||
modelsConfig.syncAfterAuthRefresh(AuthType.USE_OPENAI, 'custom-model');
|
||||
|
||||
const gc = currentGenerationConfig(modelsConfig);
|
||||
expect(gc.model).toBe('model-a');
|
||||
expect(gc.model).toBe('custom-model');
|
||||
// Settings-sourced generation config should be preserved since modelId doesn't exist in registry
|
||||
expect(gc.samplingParams?.temperature).toBe(0.9);
|
||||
expect(gc.samplingParams?.max_tokens).toBe(999);
|
||||
expect(gc.timeout).toBe(9999);
|
||||
@@ -681,4 +678,120 @@ describe('ModelsConfig', () => {
|
||||
expect(modelsConfig.getModel()).toBe('updated-model');
|
||||
expect(modelsConfig.getGenerationConfig().model).toBe('updated-model');
|
||||
});
|
||||
|
||||
describe('getAllAvailableModels', () => {
|
||||
it('should return all models across all authTypes', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'openai-model-1',
|
||||
name: 'OpenAI Model 1',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
{
|
||||
id: 'openai-model-2',
|
||||
name: 'OpenAI Model 2',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
envKey: 'OPENAI_API_KEY',
|
||||
},
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'anthropic-model-1',
|
||||
name: 'Anthropic Model 1',
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
envKey: 'ANTHROPIC_API_KEY',
|
||||
},
|
||||
],
|
||||
gemini: [
|
||||
{
|
||||
id: 'gemini-model-1',
|
||||
name: 'Gemini Model 1',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1',
|
||||
envKey: 'GEMINI_API_KEY',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const modelsConfig = new ModelsConfig({
|
||||
modelProvidersConfig,
|
||||
});
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
|
||||
// Should include qwen-oauth models (hard-coded)
|
||||
const qwenModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(qwenModels.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include openai models
|
||||
const openaiModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_OPENAI,
|
||||
);
|
||||
expect(openaiModels.length).toBe(2);
|
||||
expect(openaiModels.map((m) => m.id)).toContain('openai-model-1');
|
||||
expect(openaiModels.map((m) => m.id)).toContain('openai-model-2');
|
||||
|
||||
// Should include anthropic models
|
||||
const anthropicModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_ANTHROPIC,
|
||||
);
|
||||
expect(anthropicModels.length).toBe(1);
|
||||
expect(anthropicModels[0].id).toBe('anthropic-model-1');
|
||||
|
||||
// Should include gemini models
|
||||
const geminiModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiModels.length).toBe(1);
|
||||
expect(geminiModels[0].id).toBe('gemini-model-1');
|
||||
});
|
||||
|
||||
it('should return empty array when no models are registered', () => {
|
||||
const modelsConfig = new ModelsConfig();
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
|
||||
// Should still include qwen-oauth models (hard-coded)
|
||||
expect(allModels.length).toBeGreaterThan(0);
|
||||
const qwenModels = allModels.filter(
|
||||
(m) => m.authType === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
expect(qwenModels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return models with correct structure', () => {
|
||||
const modelProvidersConfig: ModelProvidersConfig = {
|
||||
openai: [
|
||||
{
|
||||
id: 'test-model',
|
||||
name: 'Test Model',
|
||||
description: 'A test model',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
envKey: 'TEST_API_KEY',
|
||||
capabilities: {
|
||||
vision: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const modelsConfig = new ModelsConfig({
|
||||
modelProvidersConfig,
|
||||
});
|
||||
|
||||
const allModels = modelsConfig.getAllAvailableModels();
|
||||
const testModel = allModels.find((m) => m.id === 'test-model');
|
||||
|
||||
expect(testModel).toBeDefined();
|
||||
expect(testModel?.id).toBe('test-model');
|
||||
expect(testModel?.label).toBe('Test Model');
|
||||
expect(testModel?.description).toBe('A test model');
|
||||
expect(testModel?.authType).toBe(AuthType.USE_OPENAI);
|
||||
expect(testModel?.isVision).toBe(true);
|
||||
expect(testModel?.capabilities?.vision).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,6 +203,18 @@ export class ModelsConfig {
|
||||
return this.modelRegistry.getModelsForAuthType(authType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models across all authTypes
|
||||
*/
|
||||
getAllAvailableModels(): AvailableModel[] {
|
||||
const allModels: AvailableModel[] = [];
|
||||
for (const authType of Object.values(AuthType)) {
|
||||
const models = this.modelRegistry.getModelsForAuthType(authType);
|
||||
allModels.push(...models);
|
||||
}
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model exists for the given authType
|
||||
*/
|
||||
@@ -307,6 +319,33 @@ export class ModelsConfig {
|
||||
return this.generationConfigSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge settings generation config, preserving existing values.
|
||||
* Used when provider-sourced config is cleared but settings should still apply.
|
||||
*/
|
||||
mergeSettingsGenerationConfig(
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
if (!settingsGenerationConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of MODEL_GENERATION_CONFIG_FIELDS) {
|
||||
if (
|
||||
!(field in this._generationConfig) &&
|
||||
field in settingsGenerationConfig
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this._generationConfig as any)[field] =
|
||||
settingsGenerationConfig[field];
|
||||
this.generationConfigSources[field] = {
|
||||
kind: 'settings',
|
||||
detail: `model.generationConfig.${field}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update credentials in generation config.
|
||||
* Sets a flag to prevent syncAfterAuthRefresh from overriding these credentials.
|
||||
@@ -314,12 +353,20 @@ export class ModelsConfig {
|
||||
* When credentials are manually set, we clear all provider-sourced configuration
|
||||
* to maintain provider atomicity (either fully applied or not at all).
|
||||
* Other layers (CLI, env, settings, defaults) will participate in resolve.
|
||||
*
|
||||
* @param settingsGenerationConfig Optional generation config from settings.json
|
||||
* to merge after clearing provider-sourced config.
|
||||
* This ensures settings.model.generationConfig fields
|
||||
* (e.g., samplingParams, timeout) are preserved.
|
||||
*/
|
||||
updateCredentials(credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}): void {
|
||||
updateCredentials(
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
},
|
||||
settingsGenerationConfig?: Partial<ContentGeneratorConfig>,
|
||||
): void {
|
||||
/**
|
||||
* If any fields are updated here, we treat the resulting config as manually overridden
|
||||
* and avoid applying modelProvider defaults during the next auth refresh.
|
||||
@@ -359,6 +406,14 @@ export class ModelsConfig {
|
||||
this.strictModelProviderSelection = false;
|
||||
// Clear apiKeyEnvKey to prevent validation from requiring environment variable
|
||||
this._generationConfig.apiKeyEnvKey = undefined;
|
||||
|
||||
// After clearing provider-sourced config, merge settings.model.generationConfig
|
||||
// to ensure fields like samplingParams, timeout, etc. are preserved.
|
||||
// This follows the resolution strategy where settings.model.generationConfig
|
||||
// has lower priority than programmatic overrides but should still be applied.
|
||||
if (settingsGenerationConfig) {
|
||||
this.mergeSettingsGenerationConfig(settingsGenerationConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -587,50 +642,88 @@ export class ModelsConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Config.refreshAuth to sync state after auth refresh.
|
||||
*
|
||||
* IMPORTANT: If credentials were manually set via updateCredentials(),
|
||||
* we should NOT override them with modelProvider defaults.
|
||||
* This handles the case where user inputs credentials via OpenAIKeyPrompt
|
||||
* after removing environment variables for a previously selected model.
|
||||
* Sync state after auth refresh with fallback strategy:
|
||||
* 1. If modelId can be found in modelRegistry, use the config from modelRegistry.
|
||||
* 2. Otherwise, if existing credentials exist in resolved generationConfig from other sources
|
||||
* (not modelProviders), preserve them and update authType/modelId only.
|
||||
* 3. Otherwise, fall back to default model for the authType.
|
||||
* 4. If no default is available, leave the generationConfig incomplete and let
|
||||
* resolveContentGeneratorConfigWithSources throw exceptions as expected.
|
||||
*/
|
||||
syncAfterAuthRefresh(authType: AuthType, modelId?: string): void {
|
||||
// Check if we have manually set credentials that should be preserved
|
||||
const preserveManualCredentials = this.hasManualCredentials;
|
||||
this.strictModelProviderSelection = false;
|
||||
const previousAuthType = this.currentAuthType;
|
||||
this.currentAuthType = authType;
|
||||
|
||||
// If credentials were manually set, don't apply modelProvider defaults
|
||||
// Just update the authType and preserve the manually set credentials
|
||||
if (preserveManualCredentials && authType === AuthType.USE_OPENAI) {
|
||||
this.strictModelProviderSelection = false;
|
||||
this.currentAuthType = authType;
|
||||
// Step 1: If modelId exists in registry, always use config from modelRegistry
|
||||
// Manual credentials won't have a modelId that matches a provider model (handleAuthSelect prevents it),
|
||||
// so if modelId exists in registry, we should always use provider config.
|
||||
// This handles provider switching even within the same authType.
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
this.strictModelProviderSelection = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if there are existing credentials from other sources (not modelProviders)
|
||||
const apiKeySource = this.generationConfigSources['apiKey'];
|
||||
const baseUrlSource = this.generationConfigSources['baseUrl'];
|
||||
const hasExistingCredentials =
|
||||
(this._generationConfig.apiKey &&
|
||||
apiKeySource?.kind !== 'modelProviders') ||
|
||||
(this._generationConfig.baseUrl &&
|
||||
baseUrlSource?.kind !== 'modelProviders');
|
||||
|
||||
// Only preserve credentials if:
|
||||
// 1. AuthType hasn't changed (credentials are authType-specific), AND
|
||||
// 2. The modelId doesn't exist in the registry (if it did, we would have used provider config in Step 1), AND
|
||||
// 3. Either:
|
||||
// a. We have manual credentials (set via updateCredentials), OR
|
||||
// b. We have existing credentials
|
||||
// Note: Even if authType hasn't changed, switching to a different provider model (that exists in registry)
|
||||
// will use provider config (Step 1), not preserve old credentials. This ensures credentials change when
|
||||
// switching providers, independent of authType changes.
|
||||
const isAuthTypeChange = previousAuthType !== authType;
|
||||
const shouldPreserveCredentials =
|
||||
!isAuthTypeChange &&
|
||||
(modelId === undefined ||
|
||||
!this.modelRegistry.hasModel(authType, modelId)) &&
|
||||
(this.hasManualCredentials || hasExistingCredentials);
|
||||
|
||||
if (shouldPreserveCredentials) {
|
||||
// Preserve existing credentials, just update authType and modelId if provided
|
||||
if (modelId) {
|
||||
this._generationConfig.model = modelId;
|
||||
if (!this.generationConfigSources['model']) {
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'auth refresh (preserved credentials)',
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.strictModelProviderSelection = false;
|
||||
// Step 3: Fall back to default model for the authType
|
||||
const defaultModel =
|
||||
this.modelRegistry.getDefaultModelForAuthType(authType);
|
||||
if (defaultModel) {
|
||||
this.applyResolvedModelDefaults(defaultModel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (modelId && this.modelRegistry.hasModel(authType, modelId)) {
|
||||
const resolved = this.modelRegistry.getModel(authType, modelId);
|
||||
if (resolved) {
|
||||
// Ensure applyResolvedModelDefaults can correctly apply authType-specific
|
||||
// behavior (e.g., Qwen OAuth placeholder token) by setting currentAuthType
|
||||
// before applying defaults.
|
||||
this.currentAuthType = authType;
|
||||
this.applyResolvedModelDefaults(resolved);
|
||||
}
|
||||
} else {
|
||||
// If the provided modelId doesn't exist in the registry for the new authType,
|
||||
// use the default model for that authType instead of keeping the old model.
|
||||
// This handles the case where switching from one authType (e.g., OPENAI with
|
||||
// env vars) to another (e.g., qwen-oauth) - we should use the default model
|
||||
// for the new authType, not the old model.
|
||||
this.currentAuthType = authType;
|
||||
const defaultModel =
|
||||
this.modelRegistry.getDefaultModelForAuthType(authType);
|
||||
if (defaultModel) {
|
||||
this.applyResolvedModelDefaults(defaultModel);
|
||||
// Step 4: No default available - leave generationConfig incomplete
|
||||
// resolveContentGeneratorConfigWithSources will throw exceptions as expected
|
||||
if (modelId) {
|
||||
this._generationConfig.model = modelId;
|
||||
if (!this.generationConfigSources['model']) {
|
||||
this.generationConfigSources['model'] = {
|
||||
kind: 'programmatic',
|
||||
detail: 'auth refresh (no default model)',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,6 +751,7 @@ describe('getQwenOAuthClient', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -839,9 +840,7 @@ describe('getQwenOAuthClient', () => {
|
||||
requireCachedCredentials: true,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
).rejects.toThrow('Please use /auth to re-authenticate.');
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1007,6 +1006,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -1202,6 +1202,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -1405,6 +1406,7 @@ describe('Browser Launch and Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
originalFetch = global.fetch;
|
||||
@@ -2043,6 +2045,7 @@ describe('SharedTokenManager Integration in QwenOAuth2Client', () => {
|
||||
it('should handle TokenManagerError types correctly in getQwenOAuthClient', async () => {
|
||||
const mockConfig = {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
// Test different TokenManagerError types
|
||||
|
||||
@@ -516,9 +516,7 @@ export async function getQwenOAuthClient(
|
||||
}
|
||||
|
||||
if (options?.requireCachedCredentials) {
|
||||
throw new Error(
|
||||
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
||||
);
|
||||
throw new Error('Please use /auth to re-authenticate.');
|
||||
}
|
||||
|
||||
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
|
||||
@@ -740,11 +738,9 @@ async function authWithQwenDeviceFlow(
|
||||
// Emit device authorization event for UI integration immediately
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
||||
|
||||
// Always show the fallback message in non-interactive environments to ensure
|
||||
// users can see the authorization URL even if browser launching is attempted.
|
||||
// This is critical for headless/remote environments where browser launching
|
||||
// may silently fail without throwing an error.
|
||||
showFallbackMessage(deviceAuth.verification_uri_complete);
|
||||
if (config.isBrowserLaunchSuppressed() || !config.isInteractive()) {
|
||||
showFallbackMessage(deviceAuth.verification_uri_complete);
|
||||
}
|
||||
|
||||
// Try to open browser if not suppressed
|
||||
if (!config.isBrowserLaunchSuppressed()) {
|
||||
|
||||
@@ -118,7 +118,6 @@ describe('ChatCompressionService', () => {
|
||||
mockConfig = {
|
||||
getChatCompression: vi.fn(),
|
||||
getContentGenerator: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
@@ -110,9 +110,7 @@ export class ChatCompressionService {
|
||||
|
||||
// Don't compress if not forced and we are under the limit.
|
||||
if (!force) {
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
const contextLimit = tokenLimit(model, 'input', contentGeneratorConfig);
|
||||
if (originalTokenCount < threshold * contextLimit) {
|
||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
|
||||
@@ -35,6 +35,7 @@ export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
|
||||
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
|
||||
export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch';
|
||||
export const EVENT_AUTH = 'qwen-code.auth';
|
||||
export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback';
|
||||
|
||||
// Performance Events
|
||||
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';
|
||||
|
||||
@@ -45,6 +45,7 @@ export {
|
||||
logNextSpeakerCheck,
|
||||
logAuth,
|
||||
logSkillLaunch,
|
||||
logUserFeedback,
|
||||
} from './loggers.js';
|
||||
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
|
||||
export {
|
||||
@@ -65,6 +66,8 @@ export {
|
||||
NextSpeakerCheckEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
UserFeedbackRating,
|
||||
} from './types.js';
|
||||
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
|
||||
export type { TelemetryEvent } from './types.js';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
EVENT_INVALID_CHUNK,
|
||||
EVENT_AUTH,
|
||||
EVENT_SKILL_LAUNCH,
|
||||
EVENT_USER_FEEDBACK,
|
||||
} from './constants.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -86,6 +87,7 @@ import type {
|
||||
InvalidChunkEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
} from './types.js';
|
||||
import type { UiEvent } from './uiTelemetry.js';
|
||||
import { uiTelemetryService } from './uiTelemetry.js';
|
||||
@@ -887,3 +889,32 @@ export function logSkillLaunch(config: Config, event: SkillLaunchEvent): void {
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logUserFeedback(
|
||||
config: Config,
|
||||
event: UserFeedbackEvent,
|
||||
): void {
|
||||
const uiEvent = {
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
} as UiEvent;
|
||||
uiTelemetryService.addEvent(uiEvent);
|
||||
config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent);
|
||||
QwenLogger.getInstance(config)?.logUserFeedbackEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_USER_FEEDBACK,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `User feedback: Rating ${event.rating} for session ${event.session_id}.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ExtensionDisableEvent,
|
||||
AuthEvent,
|
||||
SkillLaunchEvent,
|
||||
UserFeedbackEvent,
|
||||
RipgrepFallbackEvent,
|
||||
EndSessionEvent,
|
||||
} from '../types.js';
|
||||
@@ -842,6 +843,21 @@ export class QwenLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logUserFeedbackEvent(event: UserFeedbackEvent): void {
|
||||
const rumEvent = this.createActionEvent('user', 'user_feedback', {
|
||||
properties: {
|
||||
session_id: event.session_id,
|
||||
rating: event.rating,
|
||||
model: event.model,
|
||||
approval_mode: event.approval_mode,
|
||||
prompt_id: event.prompt_id || '',
|
||||
},
|
||||
});
|
||||
|
||||
this.enqueueLogEvent(rumEvent);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logChatCompressionEvent(event: ChatCompressionEvent): void {
|
||||
const rumEvent = this.createActionEvent('misc', 'chat_compression', {
|
||||
properties: {
|
||||
|
||||
@@ -757,6 +757,38 @@ export class SkillLaunchEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserFeedbackRating {
|
||||
BAD = 1,
|
||||
FINE = 2,
|
||||
GOOD = 3,
|
||||
}
|
||||
|
||||
export class UserFeedbackEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'user_feedback';
|
||||
'event.timestamp': string;
|
||||
session_id: string;
|
||||
rating: UserFeedbackRating;
|
||||
model: string;
|
||||
approval_mode: string;
|
||||
prompt_id?: string;
|
||||
|
||||
constructor(
|
||||
session_id: string,
|
||||
rating: UserFeedbackRating,
|
||||
model: string,
|
||||
approval_mode: string,
|
||||
prompt_id?: string,
|
||||
) {
|
||||
this['event.name'] = 'user_feedback';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.session_id = session_id;
|
||||
this.rating = rating;
|
||||
this.model = model;
|
||||
this.approval_mode = approval_mode;
|
||||
this.prompt_id = prompt_id;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelemetryEvent =
|
||||
| StartSessionEvent
|
||||
| EndSessionEvent
|
||||
@@ -786,7 +818,8 @@ export type TelemetryEvent =
|
||||
| ToolOutputTruncatedEvent
|
||||
| ModelSlashCommandEvent
|
||||
| AuthEvent
|
||||
| SkillLaunchEvent;
|
||||
| SkillLaunchEvent
|
||||
| UserFeedbackEvent;
|
||||
|
||||
export class ExtensionDisableEvent implements BaseTelemetryEvent {
|
||||
'event.name': 'extension_disable';
|
||||
|
||||
Reference in New Issue
Block a user