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
4 changes: 3 additions & 1 deletion apps/desktop/tests/browser/plugin-runtime.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,11 @@ test("browser → production native adapter → authenticated worker: offline co
expect(listed).toContain("disabled");
await detail.getByRole("button", { name: "Activate this digest" }).click();
await continueOperation("enable");
// Native activation completes before the renderer's separate inventory
// refresh. Allow that command's bounded budget for the enabled state.
await expect(
detail.getByRole("button", { name: "Disable", exact: true }),
).toBeVisible();
).toBeVisible({ timeout: 45_000 });
await expect(detail.locator(".plugin-icon img")).toHaveJSProperty(
"naturalWidth",
128,
Expand Down
3 changes: 2 additions & 1 deletion crates/colossus-provider/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ pub(super) fn payload(
.map(|tool| {
Ok(json!({
"name":projection.tool_names.provider_name(&tool.name)?,
"description":tool.description, "input_schema":tool.input_schema
"description":tool.description,
"input_schema":compatible_anthropic_tool_schema(&tool.input_schema)?
}))
})
.collect::<Result<Vec<_>, ProviderError>>()?,
Expand Down
57 changes: 54 additions & 3 deletions crates/colossus-provider/src/anthropic/gateway_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,54 @@ fn gateway(profile: &ProviderProfile, post: bool) -> EffectGateway {
)
}

fn builtin_tool_request(profile: &ProviderProfile) -> EffectRequest {
let mut model = model_request();
model.tools = colossus_tools::builtin_specs()
.into_iter()
.map(|spec| ModelToolDefinition {
name: spec.name,
description: spec.description,
input_schema: spec.input_schema,
})
.collect();
for (name, keyword) in [
("shell.run", "oneOf"),
("user.ask", "allOf"),
("workflow.schedule.create", "allOf"),
] {
let schema = &model
.tools
.iter()
.find(|tool| tool.name == name)
.unwrap()
.input_schema;
assert!(
schema.get(keyword).is_some(),
"{name} lost its canonical {keyword}"
);
}
let mut request = provider_request(profile);
request.content["request"] = serde_json::to_value(model).unwrap();
request
}

fn assert_builtin_tool_wire(wire: &str) {
let (_, body) = wire.split_once("\r\n\r\n").unwrap();
let payload: Value = serde_json::from_str(body).unwrap();
let tools = payload["tools"].as_array().unwrap();
assert_eq!(tools.len(), colossus_tools::builtin_specs().len());
for tool in tools {
assert_eq!(tool["input_schema"]["type"], "object");
for keyword in ["oneOf", "anyOf", "allOf"] {
assert!(
tool["input_schema"].get(keyword).is_none(),
"{} retained unsupported root {keyword}",
tool["name"]
);
}
}
}

#[tokio::test]
async fn anthropic_json_uses_native_headers_and_policy_release() {
let (url, server) = one_response_server(json!({"id":"msg-test","type":"message","role":"assistant",
Expand All @@ -33,13 +81,14 @@ async fn anthropic_json_uses_native_headers_and_policy_release() {
let credentials = Arc::new(CountingCredentialResolver::new());
let executor = ProviderExecutor::with_credentials(profile.clone(), credentials.clone());
let released = gateway(&profile, true)
.execute(provider_request(&profile), &executor)
.execute(builtin_tool_request(&profile), &executor)
.await
.unwrap();
let turn: ProviderTurn = serde_json::from_slice(&released.bytes).unwrap();
assert_eq!(turn.provider, "anthropic_messages");
assert!(!String::from_utf8_lossy(&released.bytes).contains("PRIVATE_THOUGHT"));
let wire = server.await.unwrap();
assert_builtin_tool_wire(&wire);
assert!(wire.contains("POST /v1/messages HTTP/1.1"));
let headers = wire.split("\r\n\r\n").next().unwrap().to_ascii_lowercase();
assert!(headers.contains("x-api-key: unit-secret"));
Expand Down Expand Up @@ -74,13 +123,15 @@ async fn anthropic_sse_crosses_gateway_with_exact_text_and_cumulative_usage() {
let executor = ProviderExecutor::new(profile.clone());
let mut items = ReleasedItems::default();
gateway(&profile, true)
.execute_stream(provider_request(&profile), &executor, &mut items)
.execute_stream(builtin_tool_request(&profile), &executor, &mut items)
.await
.unwrap();
let encoded = serde_json::to_string(&items.0).unwrap();
assert!(encoded.contains("hello 🌍"));
assert!(encoded.contains("\"total_tokens\":8"));
assert!(server.await.unwrap().contains("POST /v1/messages"));
let wire = server.await.unwrap();
assert!(wire.contains("POST /v1/messages"));
assert_builtin_tool_wire(&wire);
}

#[test]
Expand Down
128 changes: 128 additions & 0 deletions crates/colossus-provider/src/anthropic/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,134 @@ fn native_endpoint_projection_and_usage() {
);
}

#[test]
fn native_tool_schema_projection_preserves_nested_constraints_and_canonical_schema() {
let mut request = request();
let canonical_schema = json!({
"type": "object",
"properties": {
"selector": {"$ref": "#/$defs/selector"},
"fallback": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"labels": {"type": "array", "items": {"allOf": [
{"type": "string"}, {"minLength": 1, "maxLength": 32}
]}}
},
"$defs": {"selector": {"oneOf": [
{"type": "string", "minLength": 1, "maxLength": 64},
{"type": "integer", "minimum": 1}
]}},
"required": ["selector"],
"additionalProperties": false,
"minProperties": 1,
"maxProperties": 3,
"oneOf": [{"required": ["selector"]}, {"required": ["missing"]}],
"anyOf": [{"required": ["selector"]}, {"required": ["fallback"]}],
"allOf": [{"minProperties": 1}],
"enum": [{"selector": "a"}],
"const": {"selector": "a"}
});
request.tools[0].input_schema = canonical_schema.clone();
let canonical_request = serde_json::to_value(&request).unwrap();
let names = ProviderToolNames::from_request(&request).unwrap();
let images = ProviderResolvedImages::default();
let mut expected = canonical_schema.clone();
for keyword in ["oneOf", "allOf", "anyOf"] {
expected.as_object_mut().unwrap().remove(keyword);
}

for streaming in [false, true] {
let wire = payload(
&request,
"test-model",
1024,
None,
streaming,
ProviderProjection::new(&names, &images),
)
.unwrap();
assert_eq!(wire["stream"], streaming);
assert_eq!(wire["tools"][0]["input_schema"], expected);
assert_eq!(serde_json::to_value(&request).unwrap(), canonical_request);
assert_eq!(request.tools[0].input_schema, canonical_schema);
}
}

#[test]
fn native_tool_schema_projection_keeps_canonical_builtin_validation() {
use colossus_ports::ToolRegistry as _;

let registry = colossus_tools::StaticToolRegistry::builtins(&[
"shell.run".into(),
"user.ask".into(),
"workflow.schedule.create".into(),
])
.unwrap();
let mut request = request();
request.tools = colossus_tools::model_definitions(&registry);
for (name, keyword) in [
("shell.run", "oneOf"),
("user.ask", "allOf"),
("workflow.schedule.create", "allOf"),
] {
let tool = request.tools.iter().find(|tool| tool.name == name).unwrap();
assert!(
tool.input_schema.get(keyword).is_some(),
"{name}: {keyword}"
);
}
let canonical_request = serde_json::to_value(&request).unwrap();
let names = ProviderToolNames::from_request(&request).unwrap();
let images = ProviderResolvedImages::default();
for streaming in [false, true] {
let wire = payload(
&request,
"test-model",
1024,
None,
streaming,
ProviderProjection::new(&names, &images),
)
.unwrap();
for projected in wire["tools"].as_array().unwrap() {
for keyword in ["oneOf", "allOf", "anyOf"] {
assert!(projected["input_schema"].get(keyword).is_none());
}
}
assert_eq!(serde_json::to_value(&request).unwrap(), canonical_request);
}

for (name, arguments, valid_arguments) in [
(
"shell.run",
json!({"justification": "Read a fixture", "command": "echo fixture", "argv": ["echo", "fixture"]}),
json!({"justification": "Read a fixture", "command": "echo fixture"}),
),
(
"user.ask",
json!({"question": "Continue?", "allow_free_form": false}),
json!({"question": "Continue?", "allow_free_form": true}),
),
] {
registry
.validate(&colossus_contracts::ToolCall {
call_id: "valid-call".into(),
name: name.into(),
arguments: valid_arguments,
})
.unwrap();
assert!(
registry
.validate(&colossus_contracts::ToolCall {
call_id: "invalid-call".into(),
name: name.into(),
arguments,
})
.is_err(),
"{name} must retain canonical validation"
);
}
}

#[test]
fn nullable_cache_usage_preserves_absence_and_rejects_invalid_counters() {
for value in [
Expand Down
17 changes: 15 additions & 2 deletions crates/colossus-provider/src/normalization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,28 @@ fn object_tool_schema(value: &Value) -> Option<&Map<String, Value>> {
}

fn compatible_openai_tool_schema(value: &Value) -> Result<Value, ProviderError> {
compatible_tool_schema(value, &["oneOf", "anyOf", "allOf", "enum", "const"])
}

pub(super) fn compatible_anthropic_tool_schema(value: &Value) -> Result<Value, ProviderError> {
compatible_tool_schema(value, &["oneOf", "anyOf", "allOf"])
}

fn compatible_tool_schema(
value: &Value,
unsupported_root_keywords: &[&str],
) -> Result<Value, ProviderError> {
let mut projected = object_tool_schema(value)
.ok_or_else(|| {
ProviderError::Configuration(
"provider tool input schema root must declare type object".into(),
)
})?
.clone();
for keyword in ["oneOf", "anyOf", "allOf", "enum", "const"] {
projected.remove(keyword);
// Only the model-facing copy is relaxed. The registry and continuation bindings
// retain the complete canonical schema for validation and authorization.
for keyword in unsupported_root_keywords {
projected.remove(*keyword);
}
Ok(Value::Object(projected))
}
Expand Down
5 changes: 5 additions & 0 deletions docs/use/providers/anthropic.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ Model discovery follows bounded catalog pagination. Missing model metadata remai
unknown. Native input limits are used as conservative context budgets with an
additional output and safety reservation.

Tool schemas omit top-level `oneOf`, `anyOf`, and `allOf` from the definitions sent
to Anthropic, which rejects those keywords at the root. Nested schemas remain
intact. Colossus still validates every tool call against the full original schema
before execution.

Leave reasoning effort unset to use the model's default. Explicit `low`, `medium`,
`high`, `xhigh`, or `max` requests use native adaptive thinking and effort controls;
the selected model must support the combination. `none` requests disabled thinking
Expand Down
Loading