Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/design/full-turn-multimodal-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Full-turn multimodal routing

## Scope

This implements Phase 1 of #6988 only: when the primary model is text-only, an explicitly agent-capable vision model may handle the complete image-bearing turn.

It does not add persistent route state, session recovery, durable visual summaries, stable image references, historical media cleanup, or later image reinspection.

## Capability gate

Full-turn routing requires both image and agent capability:

```json
{
"id": "vision-agent",
"capabilities": {
"vision": true,
"agent": true
}
}
```

Missing or false `agent` capability keeps the existing Vision Bridge transcription behavior.

## Routing

- If the primary accepts images, use the existing primary-model path.
- If the selected vision model is not agent-capable, transcribe through Vision Bridge and answer on the primary.
- If the selected vision model is agent-capable, keep the original image parts and set a turn-local exact model selector.
- The exact provider, model, and endpoint are reused for provider retries, tool execution, tool-result continuations, and blocking ACP Stop Hook continuations.
- Configured fallback models are disabled for that turn. Failure to resolve the exact route fails closed instead of sending raw image data to the primary.
- The next independent user turn clears the selector and returns to the primary. Every model request, including side queries, receives only media modalities supported by its exact target.

The full-turn selector adds a trailing NUL marker to the existing `model\0baseUrl` representation. The chat layer removes that marker before model resolution. This keeps ordinary endpoint-qualified model selections on their existing behavior.

## Context limits

LLM-based automatic chat compression remains on the primary-model path. A full-turn route skips that compression because running primary-model compression while an image turn is owned by another provider would violate the exact-route guarantee. Existing local history microcompaction and image-payload slimming still apply, and request/cache copies retain only media modalities supported by their target model. An oversized full-turn request therefore fails on the selected model.

## Entry points

Phase 1 covers the interactive TUI and ACP. Non-interactive routing is intentionally unchanged until it has an equivalent turn-local lifecycle.
11 changes: 11 additions & 0 deletions docs/users/configuration/model-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,17 @@ This auth type supports not only OpenAI's official API but also any OpenAI-compa
}
```

For a vision model that can also follow the normal Qwen Code agent policy and use tools, opt in to full-turn image routing with both capabilities:

```json
"capabilities": {
"vision": true,
"agent": true
}
```

When a text-only primary uses that model as its configured vision fallback, the complete image-bearing turn stays on that exact provider, model, and endpoint across tool calls and retries. The next independent turn returns to the primary, and each model request receives only media modalities supported by its target. Omit `agent` (or set it to `false`) to keep the safer Vision Bridge transcription flow.

### Local Self-Hosted Models (via OpenAI-compatible API)

Most local inference servers (vLLM, Ollama, LM Studio, etc.) provide an OpenAI-compatible API endpoint. Configure them using the `openai` auth type with a local `baseUrl`:
Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_resume',
'unstable_session_resume',
'session_list',
'session_info',
Comment thread
yiliang114 marked this conversation as resolved.
'session_source_metadata',
'session_prompt',
'session_cancel',
Expand Down
191 changes: 191 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3331,6 +3331,187 @@ describe('Session', () => {
expect(sent.some((part) => 'inlineData' in part)).toBe(false);
});

it('routes an agent-capable image prompt for that ACP prompt only', async () => {
const runtimeView = {
contentGenerator: {},
contentGeneratorConfig: {
model: 'vision-agent',
modalities: { image: true },
},
model: 'vision-agent',
};
const executeSpy = vi.fn().mockImplementation(async () => {
expect(core.getRuntimeContentGenerator()).toBe(runtimeView);
return {
llmContent: 'file contents',
returnDisplay: 'file contents',
};
});
mockToolRegistry.getTool.mockReturnValue({
name: 'read_file',
kind: core.Kind.Read,
build: vi.fn().mockReturnValue({
params: { path: '/tmp/test.txt' },
getDefaultPermission: vi.fn().mockResolvedValue('allow'),
getDescription: vi.fn().mockReturnValue('Read file'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
}),
});
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'vision-agent',
baseUrl: 'https://vision.example.com/v1',
agentCapable: true,
});
const resolveForModel = vi.fn().mockResolvedValue(runtimeView);
mockConfig.getBaseLlmClient = vi.fn().mockReturnValue({
resolveForModel,
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(
createStreamWithChunks([
{
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
],
},
},
]),
)
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

expect(runVisionBridgeSpy).not.toHaveBeenCalled();
expect(firstSentMessage().some((part) => 'inlineData' in part)).toBe(
true,
);
expect(mockChat.sendMessageStream).toHaveBeenNthCalledWith(
1,
'vision-agent\0https://vision.example.com/v1\0',
expect.any(Object),
expect.any(String),
);
expect(mockChat.sendMessageStream).toHaveBeenNthCalledWith(
2,
'vision-agent\0https://vision.example.com/v1\0',
expect.any(Object),
expect.any(String),
);
expect(resolveForModel).toHaveBeenCalledWith(
'vision-agent\0https://vision.example.com/v1',
{ failClosed: true },
);
expect(executeSpy).toHaveBeenCalledOnce();
expect(
agentMessageChunks().some((chunk) =>
chunk.includes('Routing this image turn'),
),
).toBe(true);
expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled();

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'next text turn' }],
});
expect(mockChat.sendMessageStream).toHaveBeenNthCalledWith(
3,
'qwen3-code-plus',
expect.any(Object),
expect.any(String),
);
expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledOnce();
});

it('clamps full-turn images before selecting the ACP route', async () => {
const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES';
const original = process.env[ENV_KEY];
process.env[ENV_KEY] = '8';
try {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'vision-agent',
baseUrl: 'https://vision.example.com/v1',
agentCapable: true,
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
const oversized = 'QUJDREVGR0hJSktMTU5PUFFSU1Q=';

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'only oversized' },
{ type: 'image', mimeType: 'image/png', data: oversized },
],
});

const firstCall = vi.mocked(mockChat.sendMessageStream).mock.calls[0];
expect(firstCall?.[0]).toBe('qwen3-code-plus');
const firstMessage = firstCall?.[1].message;
expect(
Array.isArray(firstMessage) &&
firstMessage.some(
(part) => typeof part !== 'string' && 'inlineData' in part,
),
).toBe(false);

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'one usable image' },
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
{ type: 'image', mimeType: 'image/png', data: oversized },
],
});

const secondCall = vi.mocked(mockChat.sendMessageStream).mock.calls[1];
expect(secondCall?.[0]).toBe(
'vision-agent\0https://vision.example.com/v1\0',
);
const sentParts = secondCall?.[1].message;
if (!Array.isArray(sentParts)) {
throw new Error('Expected structured message parts');
}
expect(sentParts[1]).toEqual({
inlineData: { mimeType: 'image/png', data: 'QUJD' },
});
expect(sentParts[2]).not.toHaveProperty('inlineData');
expect(sentParts[2]).toEqual(
expect.objectContaining({ text: expect.stringMatching(/omitted/i) }),
);
expect(runVisionBridgeSpy).not.toHaveBeenCalled();
expect(
agentMessageChunks().filter((chunk) =>
chunk.includes('Routing this image turn'),
),
).toHaveLength(1);
} finally {
if (original === undefined) delete process.env[ENV_KEY];
else process.env[ENV_KEY] = original;
}
});

it('strips image parts when the vision bridge is cancelled before applying', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
Expand Down Expand Up @@ -5194,6 +5375,12 @@ describe('Session', () => {

mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO);
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'vision-agent',
baseUrl: 'https://vision.example.com/v1',
agentCapable: true,
});
mockClient.extMethod = vi.fn().mockResolvedValue({
items: [
{
Expand Down Expand Up @@ -5271,9 +5458,13 @@ describe('Session', () => {
audioFallbackPart,
];
const secondCall = vi.mocked(mockChat.sendMessageStream).mock.calls[1];
expect(secondCall?.[0]).toBe(
'vision-agent\0https://vision.example.com/v1\0',
);
expect(secondCall?.[1].message).toEqual(
expect.arrayContaining(midTurnParts),
);
expect(runVisionBridgeSpy).not.toHaveBeenCalled();
expect(secondCall?.[1].message).not.toEqual(
expect.arrayContaining([
{
Expand Down
Loading
Loading