diff --git a/docs/auth.md b/docs/auth.md index 4bbbbc1..4feac2a 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -37,7 +37,7 @@ After a sign-in finishes, the client lists models with `GET /models/{provider}` The enabled-by-default `claude` extension adds Claude Pro/Max accounts through `/login` using Anthropic’s Claude Code OAuth client. It stores rotating access and refresh tokens in `$ALBEDO_HOME/auth.json` under `anthropic` (mode 0600), with refresh under `auth.lock`. The fixed `http://localhost:53692/callback` loopback redirect must be available; a pasted callback URL also works. Choose a Claude model after signing in; profiles use `extension: "claude"` and `protocol: "chat_completions"`. The picker shows Opus 5.5, Fable 5.1, Sonnet 5, Opus 5, Opus 4.8, and Haiku 4.5 only when the cached models.dev Anthropic catalog contains them. `/reload models` refreshes this availability and the models’ limits. New model families still require a picker-policy update. -Requests go to Anthropic’s Messages endpoint with Claude Code’s OAuth beta, client headers, identity system block, and tool names. Replay is saved as provider-neutral chat messages so changing models retains conversation and tool history. This is subscription access, not an Anthropic API-key profile. Anthropic can change or restrict this unofficial client behavior at any time. +Requests go to Anthropic’s Messages endpoint with Claude Code’s OAuth beta, client headers, identity system block, and tool names. Top-level tool-schema combiners are flattened into an object with their property alternatives and common required fields; nested schemas remain intact because Anthropic rejects top-level `oneOf`/`allOf`/`anyOf`. Replay is saved as provider-neutral chat messages so changing models retains conversation and tool history. This is subscription access, not an Anthropic API-key profile. Anthropic can change or restrict this unofficial client behavior at any time. ## google antigravity oauth diff --git a/src/albedo/harness/extensions/claude/albedo_claude_schema.erl b/src/albedo/harness/extensions/claude/albedo_claude_schema.erl new file mode 100644 index 0000000..cf92816 --- /dev/null +++ b/src/albedo/harness/extensions/claude/albedo_claude_schema.erl @@ -0,0 +1,80 @@ +-module(albedo_claude_schema). +%% Anthropic requires an object input_schema without top-level combiners. +%% Keep property schemas intact; the harness still validates tool arguments. +-export([normalize/1]). + +normalize(Json) -> + Root = json:decode(iolist_to_binary(Json)), + case is_map(Root) of + true -> json:encode(flatten(Root)); + false -> json:encode(#{<<"type">> => <<"object">>, <<"properties">> => #{}}) + end. + +flatten(Root) -> + All = branches(Root, <<"allOf">>), + Unions = branches(Root, <<"oneOf">>) ++ branches(Root, <<"anyOf">>), + case lists:any(fun(K) -> maps:is_key(K, Root) end, + [<<"allOf">>, <<"oneOf">>, <<"anyOf">>]) of + false -> Root; + true -> + Base = maps:without([<<"allOf">>, <<"oneOf">>, <<"anyOf">>], Root), + Props = lists:foldl(fun(B, P) -> maps:merge(properties(B), P) end, + properties(Base), All ++ Unions), + Shared = common_required(Unions), + Required = lists:usort(required(Base) ++ + lists:append([required(B) || B <- All]) ++ Shared), + Object = Base#{<<"type">> => <<"object">>, <<"properties">> => Props, + <<"required">> => Required}, + describe_union(Object, Root) + end. + +branches(Root, Key) -> + case maps:get(Key, Root, []) of + Values when is_list(Values) -> [V || V <- Values, is_map(V)]; + _ -> [] + end. + +properties(Node) -> + case maps:get(<<"properties">>, Node, #{}) of + Props when is_map(Props) -> Props; + _ -> #{} + end. + +required(Node) -> + case maps:get(<<"required">>, Node, []) of + Names when is_list(Names) -> [N || N <- Names, is_binary(N)]; + _ -> [] + end. + +describe_union(Object, Root) -> + Guidance = lists:filtermap(fun({Key, Prefix}) -> + case [branch_hint(B) || B <- branches(Root, Key)] of + [_, _ | _] = Alternatives -> + case lists:member(<<>>, Alternatives) of + true -> false; + false -> + Text = iolist_to_binary(lists:join(<<" or ">>, Alternatives)), + {true, <>} + end; + _ -> false + end + end, [{<<"oneOf">>, <<"Exactly one of: ">>}, + {<<"anyOf">>, <<"At least one of: ">>}]), + case Guidance of + [] -> Object; + _ -> + Existing = maps:get(<<"description">>, Object, <<>>), + Object#{<<"description">> => iolist_to_binary( + lists:join(<<"; ">>, [D || D <- [Existing | Guidance], D =/= <<>>]))} + end. + +branch_hint(Branch) -> + Names = case required(Branch) of + [] -> lists:sort(maps:keys(properties(Branch))); + Required -> Required + end, + iolist_to_binary(lists:join(<<" + ">>, Names)). + +common_required([]) -> []; +common_required([First | Rest]) -> + [N || N <- required(First), lists:all(fun(B) -> lists:member(N, required(B)) end, Rest)]. diff --git a/src/albedo/harness/extensions/claude/wire.gleam b/src/albedo/harness/extensions/claude/wire.gleam index 95cf5bf..0484129 100644 --- a/src/albedo/harness/extensions/claude/wire.gleam +++ b/src/albedo/harness/extensions/claude/wire.gleam @@ -78,7 +78,7 @@ pub fn encode( json.object([ #("name", json.string(claude_name(tool.name))), #("description", json.string(tool.description)), - #("input_schema", tool.parameters), + #("input_schema", normalize_schema(tool.parameters)), ]) }), ), @@ -339,6 +339,9 @@ pub fn claude_name(name: String) -> String { |> result.unwrap(name) } +@external(erlang, "albedo_claude_schema", "normalize") +pub fn normalize_schema(schema: Json) -> Json + @external(erlang, "albedo_openai_json", "base64_string") fn base64_string(data: types.ImageData) -> Json diff --git a/test/harness/claude_test.gleam b/test/harness/claude_test.gleam index 7c8505e..c9c008d 100644 --- a/test/harness/claude_test.gleam +++ b/test/harness/claude_test.gleam @@ -5,6 +5,7 @@ import albedo/harness/oauth import albedo/openai_api import albedo/openai_api/stream as reducer import albedo/openai_api/types +import gleam/dict import gleam/dynamic/decode import gleam/json import gleam/list @@ -113,6 +114,87 @@ fn list_key( list.key_find(pairs, key) } +pub fn claude_tool_schemas_flatten_only_top_level_combiners_test() { + let assert Ok(schema) = + json.parse( + "{\"type\":\"object\",\"properties\":{\"urls\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"oneOf\":[{\"required\":[\"urls\"]},{\"required\":[\"ids\"]}],\"allOf\":[{\"required\":[\"mode\"],\"properties\":{\"mode\":{\"oneOf\":[{\"type\":\"string\"},{\"type\":\"integer\"}]}}}]}", + decode.dynamic, + ) + let request = + types.Request( + "claude-opus-5-5", + None, + [types.User("hi")], + [ + types.Tool( + "contents", + "Fetch URLs or IDs", + wire.encode_value(schema), + False, + ), + ], + None, + types.defaults, + ) + let assert Ok(openai_api.Exchange(body: body, ..)) = + wire.encode("token", request) + let assert Ok(value) = json.parse(string_tree.to_string(body), decode.dynamic) + let assert Ok([tool]) = + decode.run(value, decode.at(["tools"], decode.list(decode.dynamic))) + let assert Ok("object") = + decode.run(tool, decode.at(["input_schema", "type"], decode.string)) + let assert Ok(props) = + decode.run( + tool, + decode.at( + ["input_schema", "properties"], + decode.dict(decode.string, decode.dynamic), + ), + ) + assert list.length(dict.to_list(props)) == 3 + let assert Ok(["mode"]) = + decode.run( + tool, + decode.at(["input_schema", "required"], decode.list(decode.string)), + ) + let assert Ok(hint) = + decode.run(tool, decode.at(["input_schema", "description"], decode.string)) + assert string.contains(hint, "urls or ids") + let assert Ok([_, _]) = + decode.run( + tool, + decode.at( + ["input_schema", "properties", "mode", "oneOf"], + decode.list(decode.dynamic), + ), + ) + let assert Error(_) = + decode.run(tool, decode.at(["input_schema", "oneOf"], decode.dynamic)) + let assert Error(_) = + decode.run(tool, decode.at(["input_schema", "allOf"], decode.dynamic)) +} + +pub fn claude_tool_schema_accepts_branch_only_root_union_test() { + let assert Ok(schema) = + json.parse( + "{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"urls\":{\"type\":\"array\"}},\"required\":[\"urls\"]},{\"type\":\"object\",\"properties\":{\"ids\":{\"type\":\"array\"}},\"required\":[\"ids\"]}]}", + decode.dynamic, + ) + let normalized = wire.normalize_schema(wire.encode_value(schema)) + let assert Ok(value) = json.parse(json.to_string(normalized), decode.dynamic) + let assert Ok(props) = + decode.run( + value, + decode.at(["properties"], decode.dict(decode.string, decode.dynamic)), + ) + assert list.length(dict.to_list(props)) == 2 + let assert Ok("object") = + decode.run(value, decode.at(["type"], decode.string)) + let assert Ok(hint) = + decode.run(value, decode.at(["description"], decode.string)) + assert string.contains(hint, "urls or ids") +} + pub fn current_claude_effort_uses_adaptive_thinking_test() { let options = types.Options(..types.defaults, effort: Some("max")) let request =