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
144 changes: 141 additions & 3 deletions src/crates/adapters/ai-adapters/src/round_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,10 +384,28 @@ fn token_index_at_byte_offset(

fn find_token_logprob(candidates: &[OpenAiTokenLogprob], token_id: u32) -> Option<f64> {
let expected = format!("token_id:{token_id}");
candidates
// Some compatible services decode top candidates despite return_tokens_as_token_ids.
// Accept only the classifier's two known token spellings; the generated
// token ID and semantic label position are still validated by the caller.
let decoded = match token_id {
SIMPLE_TOKEN_ID => "simple",
NON_SIMPLE_FIRST_TOKEN_ID => "non",
_ => return None,
};
let mut observed_logprob = None;
for candidate in candidates
.iter()
.find(|candidate| candidate.token == expected)
.map(|candidate| candidate.logprob)
.filter(|candidate| candidate.token == expected || candidate.token == decoded)
{
if !candidate.logprob.is_finite() || candidate.logprob > 0.0 {
return None;
}
if observed_logprob.is_some_and(|previous| previous != candidate.logprob) {
return None;
}
observed_logprob = Some(candidate.logprob);
}
observed_logprob
}

fn parse_router_route(raw_output: &str) -> Result<(bool, bool), RouterClientError> {
Expand Down Expand Up @@ -758,4 +776,124 @@ mod tests {

assert!(extract_simple_probability(&choice, raw_output, true).is_none());
}

fn classifier_choice(candidates: &[(&str, f64)]) -> OpenAiChatChoice {
OpenAiChatChoice {
message: OpenAiResponseMessage { content: None },
logprobs: Some(OpenAiChatLogprobs {
content: Some(vec![
OpenAiChatLogprobContent {
bytes: Some(br#"{"simple_type":""#.to_vec()),
top_logprobs: Vec::new(),
},
OpenAiChatLogprobContent {
bytes: Some(b"simple".to_vec()),
top_logprobs: candidates
.iter()
.map(|(token, logprob)| OpenAiTokenLogprob {
token: (*token).into(),
logprob: *logprob,
})
.collect(),
},
OpenAiChatLogprobContent {
bytes: Some(br#""}"#.to_vec()),
top_logprobs: Vec::new(),
},
]),
}),
token_ids: Some(vec![1, SIMPLE_TOKEN_ID, 2]),
}
}

#[test]
fn decoded_classifier_candidates_preserve_two_class_confidence() {
for candidates in [
vec![("simple", -0.2), ("non", -1.4)],
vec![("token_id:22944", -0.2), ("token_id:6280", -1.4)],
vec![("simple", -0.2), ("token_id:6280", -1.4)],
vec![
("simple", -0.2),
("token_id:22944", -0.2),
("non", -1.4),
("non", -1.4),
],
] {
let choice = classifier_choice(&candidates);
let probability =
extract_simple_probability(&choice, r#"{"simple_type":"simple"}"#, true).unwrap();
assert!((probability - 0.768_524_783_5).abs() < 1e-9);
}
let mut choice = classifier_choice(&[("simple", -1.4), ("non", -0.2)]);
choice.token_ids.as_mut().unwrap()[1] = NON_SIMPLE_FIRST_TOKEN_ID;
let tokens = choice.logprobs.as_mut().unwrap().content.as_mut().unwrap();
tokens[1].bytes = Some(b"non".to_vec());
tokens[2].bytes = Some(br#"_simple"}"#.to_vec());
let probability =
extract_simple_probability(&choice, r#"{"simple_type":"non_simple"}"#, false).unwrap();
assert!((probability - 0.231_475_216_5).abs() < 1e-9);
}

#[test]
fn decoded_classifier_candidates_require_generated_id_and_label_position() {
let raw = r#"{"simple_type":"simple"}"#;
let mut wrong_id = classifier_choice(&[("simple", -0.2), ("non", -1.4)]);
wrong_id.token_ids.as_mut().unwrap()[1] = NON_SIMPLE_FIRST_TOKEN_ID;
assert!(extract_simple_probability(&wrong_id, raw, true).is_none());

let mut wrong_position = classifier_choice(&[("simple", -0.2), ("non", -1.4)]);
let tokens = wrong_position
.logprobs
.as_mut()
.unwrap()
.content
.as_mut()
.unwrap();
tokens[0].top_logprobs = std::mem::take(&mut tokens[1].top_logprobs);
assert!(extract_simple_probability(&wrong_position, raw, true).is_none());

let mut wrong_alignment = classifier_choice(&[("simple", -0.2), ("non", -1.4)]);
wrong_alignment.token_ids.as_mut().unwrap().pop();
assert!(extract_simple_probability(&wrong_alignment, raw, true).is_none());
}

#[test]
fn decoded_classifier_candidates_require_both_exact_classes() {
for candidates in [
vec![("simple", -0.2)],
vec![("non", -1.4)],
vec![("simple", -0.2), ("non_simple", -1.4)],
vec![(" simple", -0.2), ("non", -1.4)],
vec![("simple", -0.2), ("token_id:999", -1.4)],
] {
let choice = classifier_choice(&candidates);
assert!(
extract_simple_probability(&choice, r#"{"simple_type":"simple"}"#, true).is_none()
);
}
}

#[test]
fn classifier_candidates_reject_conflicting_and_invalid_probabilities() {
for candidates in [
vec![("simple", -0.2), ("simple", -0.3), ("non", -1.4)],
vec![("simple", -0.2), ("token_id:22944", -0.3), ("non", -1.4)],
vec![("simple", -0.2), ("non", -1.4), ("token_id:6280", -1.5)],
vec![
("token_id:22944", -0.2),
("token_id:22944", -0.3),
("token_id:6280", -1.4),
],
vec![("simple", f64::NAN), ("non", -1.4)],
vec![("simple", -0.2), ("non", f64::INFINITY)],
vec![("simple", f64::NEG_INFINITY), ("non", -1.4)],
vec![("simple", 0.1), ("non", -1.4)],
vec![("token_id:22944", -0.2), ("token_id:6280", 0.1)],
] {
let choice = classifier_choice(&candidates);
assert!(
extract_simple_probability(&choice, r#"{"simple_type":"simple"}"#, true).is_none()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ fn product_service_config(config: &AIConfig) -> OpenBitFunResult<HttpRoundModelR
"Router requires an API-key service; subscription authentication is unsupported".into(),
));
}
// Router classification uses Chat Completions directly, rather than the
// model's normal chat adapter. Reject other formats before deriving an
// endpoint or sending their credentials with a different wire protocol.
if !model.provider.trim().eq_ignore_ascii_case("openai") {
return Err(OpenBitFunError::Configuration(
"Router requires OpenAI Chat Completions API format; select an OpenAI-compatible classifier service".into(),
));
}
if !(0.0..=1.0).contains(&setup.simple_threshold) || setup.timeout_ms == 0 {
return Err(OpenBitFunError::Configuration(
"Router threshold must be between 0 and 1 and timeout must be positive".into(),
Expand Down Expand Up @@ -468,6 +476,7 @@ mod tests {
config.models.push(AIModelConfig {
id: id.into(),
enabled: true,
provider: "openai".into(),
base_url: endpoint.into(),
model_name: "router-fixture".into(),
api_key: "fixture-token".into(),
Expand All @@ -480,6 +489,33 @@ mod tests {
config
}

#[tokio::test]
async fn router_probe_rejects_incompatible_api_formats_without_provider_io() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
for provider in ["anthropic", "response", "responses", "gemini", ""] {
let mut config = connection_fixture(&endpoint);
config.model_routing.timeout_ms = 50;
config.models[2].provider = provider.into();
config.models[2].request_url = Some(format!("{endpoint}/v1/messages"));
config.default_models.economy = None;
let status = status_for_config(&config, Some("smart"), true, true).await;
assert!(!status.ready);
assert_eq!(status.connected, Some(false));
assert!(status.issue.as_deref().unwrap().contains("economy model"));
assert!(status
.connection_issue
.as_deref()
.unwrap()
.contains("requires OpenAI Chat Completions API format"));
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
}

fn classifier_response() -> Value {
json!({"choices": [{
"message": {"content": "{\"simple_type\":\"simple\"}"},
Expand Down Expand Up @@ -682,6 +718,7 @@ mod tests {
config.models.push(AIModelConfig {
id: "router-service".into(),
enabled: true,
provider: " OpenAI ".into(),
base_url: "https://router.example.invalid/v1/".into(),
model_name: "router-fixture".into(),
api_key: "fixture-token".into(),
Expand Down Expand Up @@ -735,6 +772,7 @@ mod tests {
config.models.push(AIModelConfig {
id: "router-service".into(),
enabled: true,
provider: "openai".into(),
base_url: "https://router.example.invalid/v1".into(),
model_name: "router-fixture".into(),
..Default::default()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,87 @@ describe('Router setup', () => {
expect(document.body.textContent).not.toContain('router.connected'); expect(onClose).not.toHaveBeenCalled();
});

it('prefers a Router connection issue over a separate readiness issue', async () => {
await render();
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValue({
...routingStatus(false),
connectionIssue: 'Router returned HTTP 401: api_key=private-secret',
issue: 'Configure an enabled economy model before using smart mode',
});
await check();
expectState('router', 'failed');
expect(row('router').textContent).toContain('router.connectionFailures.authentication');
expect(row('router').textContent).not.toContain('router.connectionChecks.unconfigured');
expect(document.body.textContent).not.toContain('private-secret');
});

it.each([
['Router requires OpenAI Chat Completions API format; select an OpenAI-compatible classifier service', 'protocol'],
['Router protocol mismatch: classifier token IDs and logprobs are required', 'protocol'],
['Router response lacks classifier token logprobs', 'protocol'],
['Failed to decode Router response: invalid response body', 'protocol'],
['Router returned HTTP 401', 'authentication'],
['Router returned HTTP 403', 'authentication'],
['Router returned HTTP 404', 'addressOrModel'],
['Invalid Router URL', 'addressOrModel'],
['Router returned HTTP 429', 'rateLimited'],
['Router returned HTTP 500', 'serviceUnavailable'],
['Router returned HTTP 503', 'serviceUnavailable'],
['Router returned HTTP 418', 'http'],
['Router deadline expired during classification after queue admission', 'network'],
['Router request failed: connection refused', 'network'],
])('localizes a legacy Router issue without displaying its raw contents: %s', async (issue, category) => {
await render();
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValue({ ...routingStatus(false), issue });
await check();
expectState('router', 'failed');
expect(row('router').textContent).toContain(`router.connectionFailures.${category}`);
expect(document.body.textContent).not.toContain(issue);
});

it('reports an unconfigured Router without exposing the host diagnostic', async () => {
await render();
const issue = 'Configure a Router service model in model settings';
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValue({ ...routingStatus(false), issue });
await check();
expectState('router', 'failed');
expect(row('router').textContent).toContain('router.connectionChecks.unconfigured');
expect(document.body.textContent).not.toContain(issue);
});

it('gives safe retry guidance for an unknown connection issue without substituting a readiness issue', async () => {
await render();
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValue({
...routingStatus(false), connectionIssue: 'provider body api_key=private-secret /private/host/config',
issue: 'Router returned HTTP 404',
});
await check();
expectState('router', 'failed');
expect(row('router').textContent).toContain('router.checkFailed');
expect(row('router').textContent).not.toContain('router.connectionFailures.addressOrModel');
expect(document.body.textContent).not.toContain('private-secret');
expect(document.body.textContent).not.toContain('/private/host/config');
});

it('does not display stale failure details when the explicit Router probe succeeds', async () => {
await render();
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValue({
...routingStatus(true), connectionIssue: 'Router returned HTTP 401', issue: 'private-old-failure',
});
await check();
expectState('router', 'connected');
expect(row('router').querySelector('.model-router-settings__check-detail')).toBeNull();
expect(row('router').textContent).not.toContain('router.connectionFailures');
expect(document.body.textContent).not.toContain('private-old-failure');
});

it('localizes a rejected protocol check containing a tokenizer file error', async () => {
await render();
vi.mocked(agentAPI.getModelRoutingStatus).mockResolvedValueOnce({ apiVersion: 1, available: true, estimatedTokenBudgetSupported: true } as Awaited<ReturnType<typeof agentAPI.getModelRoutingStatus>>)
.mockRejectedValueOnce(new Error('Failed to load Router tokenizer from /host/tokenizer.json'));
await act(async () => button('router.test').click());
expectState('router', 'failed');
expect(row('router').textContent).toContain('router.checkFailed');
expect(document.body.textContent).not.toContain('Failed to load Router tokenizer');
expect(onClose).not.toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,38 @@ export function ModelRouterSettings({ models, onClose, onSaved, onManageModels,
log.warn('Smart-mode model connection check failed', { role });
}
});
const routerFailureDetail = (issue: unknown): string => {
// Host/provider diagnostics may contain credentials or paths. Only select
// fixed translated advice; never render any part of the diagnostic itself.
const reason = typeof issue === 'string' ? issue : '';
if (/Router requires OpenAI Chat Completions API format|Router protocol mismatch|Router response lacks classifier token logprobs|Failed to decode Router response|Router (?:output is invalid JSON|response did not contain)/i.test(reason)) {
return t('router.connectionFailures.protocol');
}
const httpStatus = /\bRouter returned HTTP ([1-5]\d{2})\b/i.exec(reason)?.[1];
if (httpStatus === '401' || httpStatus === '403') return t('router.connectionFailures.authentication');
if (httpStatus === '404' || /Invalid Router URL|Router URL must use HTTP\(S\)/i.test(reason)) {
return t('router.connectionFailures.addressOrModel');
}
if (httpStatus === '429') return t('router.connectionFailures.rateLimited');
if (httpStatus?.startsWith('5')) return t('router.connectionFailures.serviceUnavailable');
if (httpStatus) return t('router.connectionFailures.http');
if (/Router (?:request failed|deadline expired)|\b(?:connection refused|connect error|dns error|timed out)\b/i.test(reason)) {
return t('router.connectionFailures.network');
}
if (/Configure a Router service model|Configure an enabled (?:primary|economy) model/i.test(reason)) {
return t('router.connectionChecks.unconfigured');
}
return t('router.checkFailed');
};
const routerCheck = async () => {
try {
const status = await agentAPI.getModelRoutingStatus({ probe: true });
update('router', { state: status.connected ? 'connected' : 'failed' });
update('router', {
state: status.connected ? 'connected' : 'failed',
detail: status.connected ? undefined : routerFailureDetail(status.connectionIssue ?? status.issue),
});
} catch {
update('router', { state: 'failed' });
update('router', { state: 'failed', detail: t('router.checkFailed') });
log.warn('Smart-mode Router connection check failed');
}
};
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/locales/en-US/settings/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,15 @@
"failed": "Connection failed",
"unconfigured": "No available model configured"
},
"connectionFailures": {
"protocol": "Choose an OpenAI Chat Completions classifier service that returns token IDs and logprobs",
"authentication": "Router authentication failed; check the API key and service permissions",
"addressOrModel": "Check the Router service address and model name; the endpoint or model may not exist",
"rateLimited": "Router requests are rate limited; wait and retry",
"serviceUnavailable": "The Router service is temporarily unavailable; wait and retry",
"http": "The Router returned an unsuccessful HTTP response; check service settings and retry",
"network": "The Router could not be reached or timed out; check the service, network, and timeout settings"
},
"notReady": "Router not ready",
"unsupported": "This execution host does not support smart routing",
"tokenBudgetUnsupported": "This execution host still requires a tokenizer file; upgrade it before configuring smart routing",
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,15 @@
"failed": "连接失败",
"unconfigured": "未配置可用模型"
},
"connectionFailures": {
"protocol": "请选择支持 OpenAI Chat Completions 且返回 token IDs 和 logprobs 的分类服务",
"authentication": "Router 认证失败,请检查 API Key 和服务访问权限",
"addressOrModel": "请检查 Router 服务地址和模型名称,接口或模型可能不存在",
"rateLimited": "Router 请求受到限流,请稍后重试",
"serviceUnavailable": "Router 服务暂时不可用,请稍后重试",
"http": "Router 返回非成功的 HTTP 响应,请检查服务设置后重试",
"network": "无法连接 Router 或请求超时,请检查服务、网络和超时设置"
},
"notReady": "路由未就绪",
"unsupported": "当前执行端不支持智能路由",
"tokenBudgetUnsupported": "当前执行主机仍需要分词表文件,请升级后再配置智能路由",
Expand Down
Loading
Loading