Skip to content

Commit 7e84992

Browse files
authored
[codex] fix(providers): normalize qwen system messages (#725)
1 parent 8801f16 commit 7e84992

9 files changed

Lines changed: 652 additions & 8 deletions

File tree

.github/workflows/provider-integration.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,83 @@ jobs:
324324
if-no-files-found: ignore
325325
retention-days: 14
326326

327+
ollama-qwen-live-e2e:
328+
name: Ollama Qwen Live E2E
329+
runs-on: ubuntu-latest
330+
permissions:
331+
contents: read
332+
continue-on-error: true
333+
env:
334+
MOLTIS_E2E_OLLAMA_QWEN_LIVE: "1"
335+
MOLTIS_E2E_OLLAMA_QWEN_MODEL: qwen2.5:0.5b
336+
steps:
337+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
338+
with:
339+
persist-credentials: false
340+
341+
- uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master
342+
with:
343+
toolchain: stable
344+
345+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
346+
with:
347+
node-version: "22"
348+
package-manager-cache: false
349+
350+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
351+
with:
352+
shared-key: ollama-qwen-live-e2e-v1
353+
cache-all-crates: true
354+
355+
- name: Install build dependencies
356+
run: |
357+
sudo apt-get update
358+
sudo apt-get install -y cmake build-essential clang libclang-dev pkg-config curl zstd
359+
360+
- name: Install Ollama
361+
env:
362+
OLLAMA_VERSION: v0.20.7
363+
OLLAMA_TARBALL: ollama-linux-amd64.tar.zst
364+
OLLAMA_TARBALL_SHA256: 193c41ffee30411a76af4484dae9cdd4c2d6f8f877b7096925c36f2489af131f
365+
run: |
366+
curl -fsSL -o ollama.tar.zst "https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/${OLLAMA_TARBALL}"
367+
echo "${OLLAMA_TARBALL_SHA256} ollama.tar.zst" | sha256sum -c -
368+
sudo tar --use-compress-program=unzstd -C /usr -xf ollama.tar.zst
369+
ollama --version
370+
371+
- name: Install npm dependencies
372+
working-directory: crates/web/ui
373+
run: npm ci
374+
375+
- name: Build Tailwind CSS
376+
run: |
377+
./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64
378+
cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh
379+
380+
- name: Build moltis binary
381+
run: cargo build --bin moltis
382+
383+
- name: Install Playwright browsers
384+
working-directory: crates/web/ui
385+
run: npx playwright install --with-deps chromium
386+
387+
- name: Run Ollama Qwen live E2E
388+
working-directory: crates/web/ui
389+
env:
390+
CI: "true"
391+
run: npx playwright test --project=ollama-qwen-live e2e/specs/ollama-qwen-live.spec.js
392+
393+
- name: Upload Ollama Qwen live E2E results
394+
if: ${{ !cancelled() }}
395+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
396+
with:
397+
name: ollama-qwen-live-e2e-${{ github.run_id }}-${{ github.run_attempt }}
398+
path: |
399+
crates/web/ui/playwright-report/
400+
crates/web/ui/test-results/
401+
if-no-files-found: ignore
402+
retention-days: 14
403+
327404
summary:
328405
name: Integration Summary
329406
runs-on: ubuntu-latest
@@ -333,6 +410,7 @@ jobs:
333410
- provider-tests
334411
- provider-e2e-scenarios
335412
- openai-live-e2e
413+
- ollama-qwen-live-e2e
336414
if: always()
337415
steps:
338416
- name: Report

crates/providers/src/openai/provider/request.rs

Lines changed: 214 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ use std::collections::{HashMap, HashSet};
22

33
use tracing::warn;
44

5-
use moltis_agents::model::ChatMessage;
5+
use {crate::raw_model_id, moltis_agents::model::ChatMessage};
66

77
use super::OpenAiProvider;
88

9+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10+
enum SystemMessageRewriteStrategy {
11+
None,
12+
MergeLeadingSystem,
13+
InlineIntoFirstUser,
14+
}
15+
916
impl OpenAiProvider {
1017
/// Returns `true` when this provider targets an Anthropic model via
1118
/// OpenRouter, which supports prompt caching when `cache_control`
@@ -117,18 +124,60 @@ impl OpenAiProvider {
117124
|| self.base_url.to_ascii_lowercase().contains("minimax")
118125
}
119126

120-
/// For providers that reject `role: "system"` in the messages array,
121-
/// extract all system messages from `body["messages"]`, join their
122-
/// content, and prepend it to the first user message.
127+
fn is_custom_openai_compatible_provider(&self) -> bool {
128+
self.provider_name.starts_with("custom-")
129+
}
130+
131+
fn is_alibaba_qwen_backend(&self) -> bool {
132+
self.provider_name.eq_ignore_ascii_case("alibaba-coding")
133+
|| self.provider_name.eq_ignore_ascii_case("alibaba")
134+
|| self.provider_name.eq_ignore_ascii_case("dashscope-coding")
135+
|| self.base_url.contains("dashscope.aliyuncs.com")
136+
|| self.base_url.contains("alibabacloud.com")
137+
}
138+
139+
fn is_qwen_single_system_backend(&self) -> bool {
140+
self.provider_name.eq_ignore_ascii_case("ollama")
141+
|| self.provider_name.to_ascii_lowercase().contains("ollama")
142+
|| self.is_custom_openai_compatible_provider()
143+
|| self.is_alibaba_qwen_backend()
144+
}
145+
146+
/// Some backends ship chat templates that only accept a single system
147+
/// message at the front of the conversation. Qwen-based OpenAI-compatible
148+
/// backends commonly behave this way (e.g. llama.cpp chat templates).
149+
fn requires_single_leading_system_message(&self) -> bool {
150+
raw_model_id(&self.model)
151+
.to_ascii_lowercase()
152+
.contains("qwen")
153+
&& self.is_qwen_single_system_backend()
154+
}
155+
156+
fn system_message_rewrite_strategy(&self) -> SystemMessageRewriteStrategy {
157+
if self.rejects_system_role() {
158+
return SystemMessageRewriteStrategy::InlineIntoFirstUser;
159+
}
160+
if self.requires_single_leading_system_message() {
161+
return SystemMessageRewriteStrategy::MergeLeadingSystem;
162+
}
163+
SystemMessageRewriteStrategy::None
164+
}
165+
166+
/// Rewrite system messages for providers with stricter chat template rules.
123167
///
124168
/// MiniMax's `/v1/chat/completions` endpoint returns error 2013 for
125169
/// `role: "system"` entries and silently ignores a top-level `"system"`
126170
/// field. The only reliable way to deliver the system prompt is to
127171
/// inline it into the first user message.
128172
///
173+
/// Qwen-based OpenAI-compatible backends often only accept a single system
174+
/// message at the very front. For those, join all system messages with
175+
/// blank lines and emit exactly one leading `role: "system"` message.
176+
///
129177
/// Must be called on the request body **after** it is fully assembled.
130178
pub(super) fn apply_system_prompt_rewrite(&self, body: &mut serde_json::Value) {
131-
if !self.rejects_system_role() {
179+
let rewrite_strategy = self.system_message_rewrite_strategy();
180+
if matches!(rewrite_strategy, SystemMessageRewriteStrategy::None) {
132181
return;
133182
}
134183
let Some(messages) = body
@@ -145,7 +194,10 @@ impl OpenAiProvider {
145194
{
146195
system_parts.push(content.to_string());
147196
} else if msg.get("content").is_some() {
148-
warn!("MiniMax system message has non-string content; it will be dropped");
197+
warn!(
198+
?rewrite_strategy,
199+
"system message has non-string content; it will be dropped"
200+
);
149201
}
150202
return false;
151203
}
@@ -156,6 +208,20 @@ impl OpenAiProvider {
156208
}
157209
let system_text = system_parts.join("\n\n");
158210

211+
if matches!(
212+
rewrite_strategy,
213+
SystemMessageRewriteStrategy::MergeLeadingSystem
214+
) {
215+
messages.insert(
216+
0,
217+
serde_json::json!({
218+
"role": "system",
219+
"content": system_text,
220+
}),
221+
);
222+
return;
223+
}
224+
159225
// Find the first user message and prepend system content to it.
160226
let system_block =
161227
format!("[System Instructions]\n{system_text}\n[End System Instructions]\n\n");
@@ -347,3 +413,145 @@ fn assign_openai_tool_call_id(
347413
remapped_tool_call_ids.insert(raw.to_string(), candidate.clone());
348414
candidate
349415
}
416+
417+
#[cfg(test)]
418+
mod tests {
419+
use secrecy::Secret;
420+
421+
use super::*;
422+
423+
fn provider(model: &str, provider_name: &str, base_url: &str) -> OpenAiProvider {
424+
OpenAiProvider::new_with_name(
425+
Secret::new("test-key".to_string()),
426+
model.to_string(),
427+
base_url.to_string(),
428+
provider_name.to_string(),
429+
)
430+
}
431+
432+
fn body_messages(body: &serde_json::Value) -> &[serde_json::Value] {
433+
let Some(messages) = body.get("messages").and_then(serde_json::Value::as_array) else {
434+
panic!("messages should be an array");
435+
};
436+
messages
437+
}
438+
439+
#[test]
440+
fn system_message_rewrite_qwen_merges_multiple_messages_into_one_leading_message() {
441+
let provider = provider(
442+
"qwen3:0.6b",
443+
"custom-ollama-qwen",
444+
"http://127.0.0.1:11435/v1",
445+
);
446+
let mut body = serde_json::json!({
447+
"messages": [
448+
{"role": "system", "content": "You are a helpful assistant."},
449+
{"role": "user", "content": "hello"},
450+
{"role": "assistant", "content": "hi"},
451+
{"role": "system", "content": "The current user datetime is 2026-04-15 18:22:00 UTC."},
452+
{"role": "user", "content": "what time is it?"}
453+
]
454+
});
455+
456+
provider.apply_system_prompt_rewrite(&mut body);
457+
458+
let messages = body_messages(&body);
459+
assert_eq!(messages.len(), 4);
460+
assert_eq!(messages[0]["role"], "system");
461+
assert_eq!(
462+
messages[0]["content"],
463+
"You are a helpful assistant.\n\nThe current user datetime is 2026-04-15 18:22:00 UTC."
464+
);
465+
assert_eq!(messages[1]["role"], "user");
466+
assert_eq!(messages[2]["role"], "assistant");
467+
assert_eq!(messages[3]["role"], "user");
468+
}
469+
470+
#[test]
471+
fn system_message_rewrite_minimax_inlines_messages_into_first_user_message() {
472+
let provider = provider("MiniMax-M2.7", "minimax", "https://api.minimax.io/v1");
473+
let mut body = serde_json::json!({
474+
"messages": [
475+
{"role": "system", "content": "You are a helpful assistant."},
476+
{"role": "user", "content": "hello"},
477+
{"role": "system", "content": "The current user datetime is 2026-04-15 18:22:00 UTC."}
478+
]
479+
});
480+
481+
provider.apply_system_prompt_rewrite(&mut body);
482+
483+
let messages = body_messages(&body);
484+
assert_eq!(messages.len(), 1);
485+
assert_eq!(messages[0]["role"], "user");
486+
assert_eq!(
487+
messages[0]["content"],
488+
"[System Instructions]\nYou are a helpful assistant.\n\nThe current user datetime is 2026-04-15 18:22:00 UTC.\n[End System Instructions]\n\nhello"
489+
);
490+
}
491+
492+
#[test]
493+
fn system_message_rewrite_default_openai_request_is_unchanged() {
494+
let provider = provider("gpt-4o-mini", "openai", "https://api.openai.com/v1");
495+
let mut body = serde_json::json!({
496+
"messages": [
497+
{"role": "system", "content": "sys1"},
498+
{"role": "user", "content": "hello"},
499+
{"role": "system", "content": "sys2"}
500+
]
501+
});
502+
503+
provider.apply_system_prompt_rewrite(&mut body);
504+
505+
let messages = body_messages(&body);
506+
assert_eq!(messages.len(), 3);
507+
assert_eq!(messages[0]["role"], "system");
508+
assert_eq!(messages[1]["role"], "user");
509+
assert_eq!(messages[2]["role"], "system");
510+
}
511+
512+
#[test]
513+
fn system_message_rewrite_qwen_model_on_openai_provider_is_unchanged() {
514+
let provider = provider("qwen3-coder-plus", "openai", "https://api.openai.com/v1");
515+
let mut body = serde_json::json!({
516+
"messages": [
517+
{"role": "system", "content": "sys1"},
518+
{"role": "user", "content": "hello"},
519+
{"role": "system", "content": "sys2"}
520+
]
521+
});
522+
523+
provider.apply_system_prompt_rewrite(&mut body);
524+
525+
let messages = body_messages(&body);
526+
assert_eq!(messages.len(), 3);
527+
assert_eq!(messages[0]["role"], "system");
528+
assert_eq!(messages[0]["content"], "sys1");
529+
assert_eq!(messages[1]["role"], "user");
530+
assert_eq!(messages[2]["role"], "system");
531+
assert_eq!(messages[2]["content"], "sys2");
532+
}
533+
534+
#[test]
535+
fn system_message_rewrite_alibaba_qwen_merges_multiple_messages_into_one_leading_message() {
536+
let provider = provider(
537+
"qwen3.5-plus",
538+
"alibaba-coding",
539+
"https://coding-intl.dashscope.aliyuncs.com/v1",
540+
);
541+
let mut body = serde_json::json!({
542+
"messages": [
543+
{"role": "system", "content": "sys1"},
544+
{"role": "user", "content": "hello"},
545+
{"role": "system", "content": "sys2"}
546+
]
547+
});
548+
549+
provider.apply_system_prompt_rewrite(&mut body);
550+
551+
let messages = body_messages(&body);
552+
assert_eq!(messages.len(), 2);
553+
assert_eq!(messages[0]["role"], "system");
554+
assert_eq!(messages[0]["content"], "sys1\n\nsys2");
555+
assert_eq!(messages[1]["role"], "user");
556+
}
557+
}

crates/web/ui/e2e/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ app enters onboarding mode. Uses a random free port by default.
4242

4343
## Playwright Projects
4444

45-
The test suite is split into six Playwright projects:
45+
The test suite is split into seven Playwright projects, plus one opt-in live
46+
project for local Ollama/Qwen validation:
4647

4748
| Project | Port | Spec files | Notes |
4849
|---------|------|------------|-------|
@@ -52,6 +53,7 @@ The test suite is split into six Playwright projects:
5253
| `onboarding-auth` | Random free port (`MOLTIS_E2E_ONBOARDING_AUTH_PORT`) | `onboarding-auth.spec.js` | Separate server with remote-auth simulation |
5354
| `onboarding-anthropic` | Random free port (`MOLTIS_E2E_ONBOARDING_ANTHROPIC_PORT`) | `onboarding-anthropic.spec.js` | Separate server proving first-run Anthropic onboarding with zero providers at startup |
5455
| `openai-live` | Random free port (`MOLTIS_E2E_OPENAI_LIVE_PORT`) | `openai-live.spec.js` | Separate server that preserves only the existing OpenAI env and proves a real OpenAI chat turn works |
56+
| `ollama-qwen-live` | Random free port (`MOLTIS_E2E_OLLAMA_QWEN_LIVE_PORT`) + Ollama API port (`MOLTIS_E2E_OLLAMA_QWEN_API_PORT`, default `11435`) | `ollama-qwen-live.spec.js` | Opt-in server that starts a local Ollama instance, seeds a custom OpenAI-compatible Qwen provider, and proves the multiple-system-message regression is fixed |
5557

5658
## Spec Files
5759

@@ -74,6 +76,7 @@ The test suite is split into six Playwright projects:
7476
| `onboarding-auth.spec.js` | 1 | Remote onboarding auth flow with setup code and identity save |
7577
| `onboarding-anthropic.spec.js` | 1 | Anthropic onboarding from empty startup, model discovery, model selection |
7678
| `openai-live.spec.js` | 1 | Live OpenAI provider smoke test using the existing env and a real chat turn |
79+
| `ollama-qwen-live.spec.js` | 1 | Opt-in live Ollama smoke test for the custom OpenAI-compatible Qwen regression path |
7780

7881
## Shared Helpers
7982

@@ -94,6 +97,9 @@ cd crates/web/ui && npx playwright test e2e/specs/sessions.spec.js
9497
# Run a specific project
9598
npx playwright test --project=auth
9699

100+
# Run the opt-in Ollama/Qwen live project
101+
MOLTIS_E2E_OLLAMA_QWEN_LIVE=1 npx playwright test --project=ollama-qwen-live e2e/specs/ollama-qwen-live.spec.js
102+
97103
# Run with visible browser
98104
just ui-e2e-headed
99105

0 commit comments

Comments
 (0)