Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
92fbbd5
fix(route): honor custom wire on runtime routes
asto18089 Sep 29, 2026
3a2d5ed
feat(tui): capture reasoning on custom responses
asto18089 Sep 29, 2026
ac270bf
fix(route): scope the custom wire override to the resolved identity
asto18089 Sep 29, 2026
a0d1e39
fix(responses): endpoint-scope reasoning replay; capture zen responses
asto18089 Sep 29, 2026
a773fab
fix(route): bind the custom wire gate once; pin the override's edges
asto18089 Sep 29, 2026
53ebf76
fix(tui): pin wire-capture edges; readiness reads the table wire
asto18089 Sep 29, 2026
21b6f71
fix(responses): bind reasoning replay to the capture endpoint
asto18089 Sep 30, 2026
e698b13
fix(responses): derive the capture gate from the include predicate
asto18089 Sep 30, 2026
d8e92f4
fix(config): warn on unrecognized wire dialects; pin the parse
asto18089 Sep 30, 2026
8caceb8
fix(tui): thread the table wire into candidate display receipts
asto18089 Sep 30, 2026
672a2d9
test: pin the wire plumbs the audit found untested
asto18089 Sep 30, 2026
32da305
test: pin the capture-to-replay seam and the custom Anthropic transport
asto18089 Sep 30, 2026
1a429af
style: cargo fmt
asto18089 Sep 30, 2026
5e7166c
fix(responses): fail closed on fingerprint-less Custom reasoning replay
asto18089 Sep 30, 2026
a0c1643
fix(responses): gate fingerprint-less reasoning replay on the officia…
asto18089 Oct 2, 2026
6b22aaa
fix(tui): gate the prompt suggestion on the resolved route's protocol
asto18089 Oct 2, 2026
cd1762a
test: route the last named-table fixture through the helper; pin the …
asto18089 Oct 2, 2026
86dd8e8
fix(config): share the anthropic alias list; scope the dialect warnin…
asto18089 Oct 2, 2026
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
173 changes: 155 additions & 18 deletions crates/app-server/src/chat_completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use codewhale_agent::ModelRegistry;
use codewhale_config::{
ConfigApiKeyValueKind, ConfigToml, ProviderKind, auth_mode_disables_api_key,
classify_config_api_key_value, is_upstream_auth_header,
provider::WireFormat,
provider::{WireFormat, wire_dialect_override},
provider_base_url_is_official, provider_preserves_custom_base_url_model,
route::{LogicalModelRef, RouteError, RouteRequest, RouteResolver},
};
Expand Down Expand Up @@ -118,6 +118,16 @@ fn resolve_endpoint(
saved_provider_model: None,
base_url_override: Some(base_url.clone()),
limit_overrides: Vec::new(),
// The wire dialect rides the same literal `[providers.custom]` table
// that supplied the endpoint and key above (this ingress reads the
// legacy field, not the named-table map the tui route layer
// resolves), so a `wire = "responses"` table cannot be silently
// served as chat here: the resolver mints a Responses candidate and
// the handler's ChatCompletions-only guard rejects it (fail closed)
// instead of forwarding to `{base}/chat/completions`.
wire_override: (provider_kind == ProviderKind::Custom)
.then(|| wire_dialect_override(provider_cfg.wire.as_deref()))
.flatten(),
})?;
let model = route.wire_model_id().as_str().to_string();

Expand Down Expand Up @@ -1044,6 +1054,39 @@ api_key = {provider_api_key:?}
));
}

/// A `wire = "responses"` custom table must reach this pass-through's
/// resolution: the endpoint then carries the Responses wire and the
/// handler's ChatCompletions-only guard rejects the request (fail closed)
/// instead of silently forwarding a chat body to `{base}/chat/completions`
/// — the same mis-route the runtime-route fix removed from per-turn
/// clients.
#[test]
fn custom_table_wire_override_reaches_the_app_route() {
let mut config = ConfigToml {
provider: ProviderKind::Custom,
..ConfigToml::default()
};
config.providers.custom.base_url = Some("https://relay.example.test/v1".to_string());
config.providers.custom.model = Some("gpt-6-sol".to_string());
config.providers.custom.wire = Some("responses".to_string());

let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("gpt-6-sol"))
.expect("custom responses table resolves");
assert_eq!(endpoint.provider, ProviderKind::Custom);
assert_eq!(endpoint.model, "gpt-6-sol");
assert_eq!(
endpoint.wire_format,
WireFormat::Responses,
"the table's wire dialect rides the same provider_cfg as its endpoint"
);

// An ordinary chat table keeps the forwardable Chat wire.
config.providers.custom.wire = None;
let endpoint = resolve_endpoint(&config, &ModelRegistry::default(), Some("gpt-6-sol"))
.expect("custom chat table resolves");
assert_eq!(endpoint.wire_format, WireFormat::ChatCompletions);
}

#[test]
fn foreign_model_is_rejected_before_credentials_or_headers_can_cross() {
let mut config = ConfigToml {
Expand Down Expand Up @@ -1482,25 +1525,119 @@ api_key = {provider_api_key:?}
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

/// The headline fail-closed path for a `wire = "responses"` custom
/// table: the pass-through must reject with
/// `provider_wire_format_unsupported` instead of silently forwarding a
/// chat body to `{base}/chat/completions`. Posted through the real
/// router so the resolver→guard composite is pinned end to end.
#[tokio::test]
async fn non_chat_completions_provider_rejected() {
// Use the test to verify WireFormat checks work for non-ChatCompletions providers.
// Anthropic's wire format is AnthropicMessages; OpenaiCodex is Responses.
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Anthropic,
base_url: "https://api.anthropic.com".to_string(),
model: "claude-sonnet-4-20250514".to_string(),
api_key: Some("sk-ant-test".to_string()),
auth_disabled: false,
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::AnthropicMessages,
};
async fn custom_responses_wire_table_is_rejected_fail_closed() {
install_crypto_provider();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
// The base URL is never contacted: the guard fires before any
// upstream I/O.
fs::write(
&config_path,
r#"
provider = "custom"

[providers.custom]
wire = "responses"
base_url = "https://relay.example/v1"
api_key = "custom-responses-key"
model = "gpt-6-sol"
"#,
)
.expect("write config");
let state = build_state(Some(config_path), None).expect("state");
let app = app_router(state, &[]);

let body = serde_json::json!({
"messages": [{"role": "user", "content": "hello"}]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();

assert_ne!(endpoint.wire_format, WireFormat::ChatCompletions);
// The handler would reject this; we verify the wire format here.
assert_eq!(endpoint.wire_format, WireFormat::AnthropicMessages);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("error body");
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json error body");
assert_eq!(payload["error"]["code"], "provider_wire_format_unsupported");
assert_eq!(payload["error"]["type"], "unsupported_provider");
assert!(
payload["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("Responses")),
"the error names the offending dialect: {payload}"
);
}

/// Same fail-closed contract for the Anthropic dialect: a
/// `wire = "anthropic"` custom table resolves to a Messages-protocol
/// route and must be rejected by the Chat-Completions-only guard, not
/// forwarded a chat body.
#[tokio::test]
async fn custom_anthropic_wire_table_is_rejected_fail_closed() {
install_crypto_provider();
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
// The base URL is never contacted: the guard fires before any
// upstream I/O.
fs::write(
&config_path,
r#"
provider = "custom"

[providers.custom]
wire = "anthropic"
base_url = "https://relay.example/v1"
api_key = "custom-anthropic-key"
model = "custom-claude"
"#,
)
.expect("write config");
let state = build_state(Some(config_path), None).expect("state");
let app = app_router(state, &[]);

let body = serde_json::json!({
"messages": [{"role": "user", "content": "hello"}]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("error body");
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json error body");
assert_eq!(payload["error"]["code"], "provider_wire_format_unsupported");
assert_eq!(payload["error"]["type"], "unsupported_provider");
assert!(
payload["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("AnthropicMessages")),
"the error names the offending dialect: {payload}"
);
}

#[test]
Expand Down
35 changes: 19 additions & 16 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,14 @@ pub struct ProviderConfigToml {
pub context_window: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// Wire dialect preference for dual-protocol vendors (DeepSeek, MiniMax,
/// Model Studio): `openai` (Chat Completions, default) or `anthropic`
/// (Messages). Not a separate catalog provider — a power-user toggle.
/// Wire dialect override. Named custom-provider tables accept
/// `responses`, `anthropic` (or `messages`/`claude`), and `chat` or
/// `openai` (the Chat Completions default); dual-protocol built-in
/// vendors (DeepSeek, MiniMax, Model Studio) accept `openai` (default)
/// or `anthropic` (Messages). An unrecognized value falls back to the
/// default policy — custom tables log a warning as they degrade, while
/// a built-in vendor's dialect space is silently `openai`/`anthropic`.
/// Not a separate catalog provider — a power-user toggle.
#[serde(
default,
skip_serializing_if = "Option::is_none",
Expand Down Expand Up @@ -3385,6 +3390,12 @@ impl ConfigToml {
saved_provider_model: None,
base_url_override: Some(base_url.clone()),
limit_overrides: Vec::new(),
// provider_cfg above is the active custom table (named or
// legacy); its dialect is the same fact the tui route layer
// threads, so this receipt cannot disagree with the turn.
wire_override: (provider == ProviderKind::Custom)
.then(|| provider::wire_dialect_override(provider_cfg.wire.as_deref()))
.flatten(),
})
.ok();

Expand Down Expand Up @@ -4579,6 +4590,10 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
}

/// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind.
/// The `anthropic` alias tail is the canonical parse in
/// [`provider::wire_dialect_prefers_anthropic`] — the built-in dialect space
/// only ever branches openai/anthropic, so it shares that alias list rather
/// than keeping a second one that can drift.
fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
if matches!(
kind,
Expand All @@ -4589,19 +4604,7 @@ fn wire_prefers_anthropic(kind: ProviderKind, wire: Option<&str>) -> bool {
) {
return true;
}
let Some(raw) = wire.map(str::trim).filter(|value| !value.is_empty()) else {
return false;
};
let normalized = raw.to_ascii_lowercase().replace(['_', ' '], "-");
matches!(
normalized.as_str(),
"anthropic"
| "anthropic-messages"
| "messages"
| "claude"
| "anthropic-compatible"
| "anthropic-compat"
)
provider::wire_dialect_prefers_anthropic(wire)
}

fn modelstudio_mode_is_coding_plan(kind: ProviderKind, mode: Option<&str>) -> bool {
Expand Down
Loading
Loading