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
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,43 @@
# Changelog

## Version 0.60.0 (2026-08-06)

- Shorten the shared Drawer overlay and panel transitions to the Console's existing 150 ms and 200 ms motion rhythm. Limit the panel transition to transform and opacity, remove the full-screen backdrop blur and permanent compositor hint, and enable `will-change` only during an active swipe.

- Give every read-only data-table row that opens another view one shared compact trailing arrow action. Apply it to Conversations, Principals, Automation Jobs, Brain Sources, and Worker file browsers; replace the Brain Source pencil and Worker text action, fill the missing Automation Job action cell, and name each icon for its resource. Keep edit rows on their existing pencil or overflow actions, and match loading placeholders to the real action size.

- Remove the duplicate Cancel action from the shared Console resource editor footer. The header Back link is now the single way to leave these pages; dialogs, drawers, and inline editors keep Cancel when it closes or discards a real local state.

- Use one dedicated muted-layer fill and the secondary text token for every shared read-only Console value, so immutable identifiers stay distinct from editable, disabled, and static content without using the white input fill. Keep the read-only Signal Routing rule name aligned with the adjacent adapter value in size, weight, and font family.

- Keep stored Signal Routing and Identity Provider secrets out of edit responses and the browser DOM. Both APIs now use one plugin-owned encrypted-field implementation to omit secret fields, return only their stored paths, and preserve stored values when an edit leaves them blank. Replacement inputs opt out of password reuse, and adapter forms use one shared saved-secret prompt. Vertically center shared read-only values so immutable rule names and IDs align with adjacent controls, and do not mark an immutable rule name as required on the edit form.

- Give managed conversations operator-facing names instead of promoting internal session keys to titles. Name Dreaming rows from their resolved channel, peer, or knowledge scope, keep the full key as secondary technical information, and apply the Console's bounded single-line truncation rule to both names and keys.

- Make Signal Routing use normal edit and lifecycle actions without deleting its durable recovery key. Show enabled routes by default, add the Show disabled filter, and offer Edit and Disable for enabled routes or Edit and Enable for disabled routes. Add an authorized binding-detail API that restores normal connection settings, omits adapter-declared secrets, returns only their stored paths, and preserves stored secrets when an edit omits them or sends an empty value. Keep the development OpenAPI operation lookup aligned with hot-reloaded routes, regenerate the typed client, and document and test the soft-disable and safe edit contracts.

- Explain disabled model and context-length controls inside the controls without adding a second line below either field. Both controls now prompt for a provider while they are unavailable, and the context-length column stays compact and equal in height to the adjacent fields.

- Keep the model, provider, and context-length fields at the same visual height. Remove the duplicate provider prerequisite from inside the context field and restore the compact numeric column; the provider selector and provider-options guidance remain the single visible explanation of the dependency.

- Keep a sticky Console action cell in the same active background as its row while its menu is open. Moving the pointer into the portalled menu no longer makes the three-dot area flash back to the lighter card layer.

- Keep the Console row-action trigger transparent while its menu is open, so hovering a menu item does not leave a separate rectangle around the three-dot icon. Replace the fixed-width context-length column with a content-compatible flexible track so its dependency guidance stays readable without changing the guidance.

- Keep the sticky Console table action cell in the same hover and selection state as its row instead of leaving a contrasting block at the edge. Make disabled text controls use the existing disabled text token and remove their active underline, and explain that model-profile context length becomes available after a model provider is selected.

- Clear client-side Console resource filters immediately instead of rendering one stale deferred-query frame. Keep non-empty search input deferred, preserve the shared focus return, and prevent the toolbar, empty state, and result table from flashing during reset.

- Keep shared Console pages inside their narrow viewport by making route and supplementary grid tracks shrink with their content. Restore focus to the row-action trigger after its confirmation dialog closes, and move focus back into the visible toolbar after an empty filtered list clears its filter.

- Replace the free-form OpenAI and Azure OpenAI reasoning-summary and answer-detail fields with ProviderDSL-owned choices and bilingual guidance. Keep reasoning summary in the advanced group because it applies only to the Responses API. Convert the public model-profile option names to the native Responses or Chat Completions request shape before dispatch, and reject a Responses-only reasoning summary on Chat Completions instead of sending an invalid camelCase field upstream.

- Make the Console model-provider path readable without changing its visual system or provider contracts. Show each provider kind's localized registry label in the list, let credential labels and values use the full phone width before they become paired columns, and keep raw upstream quota headers in a bounded disclosure instead of letting internal diagnostic values dominate every credential card.

- Keep Agent model profiles understandable in both Console languages. Replace fixed profile IDs in card summaries and persistence notices with role names, mark required model inputs for assistive technology, give icon-only combobox clear actions a translated name instead of exposing an i18n key, and explain the advanced service-tier override with localized helper text instead of an unexplained English input.

- Keep Console navigation and action state explicit for assistive technology without changing the Ankole visual system. Give the Access sibling navigation a unique bilingual landmark name, report page refresh progress on the initiating control, and let Console dialogs and sheets pass the active locale to their built-in close button instead of announcing an English-only label.

## Version 0.59.0 (2026-08-06)

- Move Codex Job collaboration wait policy out of model instructions. Keep the shared `AGENT_JOB.md` template as an empty extension point, set the native wait minimum to one minute and the default to two minutes, and leave the maximum unset so Codex keeps its default. This reduces repeated model re-entry after empty waits tracked in openai/codex#35259.
Expand Down
4 changes: 4 additions & 0 deletions app/control_plane/config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ config :ankole, AnkoleWeb.Endpoint,

config :ankole, AnkoleWeb.Assets, dev_server: "http://127.0.0.1:3035"

# Phoenix reloads controller and route modules in development. Keep the OpenAPI
# operation lookup live as well, or a new route can use a stale operation cache.
config :open_api_spex, :cache_adapter, OpenApiSpex.Plug.NoneCache

config :ankole, Ankole.AIAgent.Library,
internal_skills_root: Path.expand("../../../internals/skills", __DIR__),
source_cache_ttl_ms: 0
Expand Down
74 changes: 74 additions & 0 deletions app/control_plane/lib/ankole/ai_gateway/openai_request_options.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
defmodule Ankole.AIGateway.OpenAIRequestOptions do
@moduledoc """
Maps Ankole's public OpenAI request options to the selected upstream API.

ProviderDSL exposes stable camelCase option names to model profiles. OpenAI
Responses and Chat Completions use different wire shapes, so this module
keeps that protocol choice out of the Console and provider forms.
"""

alias Ankole.AIGateway.UniversalAIRequest

@reasoning_summary_values ~w(auto concise detailed)
@text_verbosity_values ~w(low medium high)

@type endpoint :: :responses | :chat_completions

@doc "Returns the reasoning summary values accepted by OpenAI Responses."
@spec reasoning_summary_values() :: [String.t()]
def reasoning_summary_values, do: @reasoning_summary_values

@doc "Returns the text verbosity values accepted by OpenAI."
@spec text_verbosity_values() :: [String.t()]
def text_verbosity_values, do: @text_verbosity_values

@doc "Writes the public options to their provider-native request locations."
@spec put_provider_options(UniversalAIRequest.t() | {:error, term()}, endpoint()) ::
UniversalAIRequest.t() | {:error, term()}
def put_provider_options({:error, _reason} = error, _endpoint), do: error

def put_provider_options(%UniversalAIRequest{} = request, :responses) do
options =
(request.provider_options || %{})
|> put_nested_option("reasoningSummary", "reasoning", "summary")
|> put_nested_option("textVerbosity", "text", "verbosity")

UniversalAIRequest.put_provider_options(request, options)
end

def put_provider_options(%UniversalAIRequest{} = request, :chat_completions) do
case Map.pop(request.provider_options || %{}, "reasoningSummary") do
{nil, options} ->
UniversalAIRequest.put_provider_options(
request,
rename_option(options, "textVerbosity", "verbosity")
)

{_value, _options} ->
{:error, {:unsupported_provider_option, "reasoningSummary", "chat_completions"}}
end
end

defp put_nested_option(options, public_key, object_key, native_key) do
case Map.pop(options, public_key) do
{nil, options} ->
options

{value, options} ->
nested =
case Map.get(options, object_key) do
nested when is_map(nested) -> Map.put(nested, native_key, value)
_value -> %{native_key => value}
end

Map.put(options, object_key, nested)
end
end

defp rename_option(options, public_key, native_key) do
case Map.pop(options, public_key) do
{nil, options} -> options
{value, options} -> Map.put(options, native_key, value)
end
end
end
21 changes: 19 additions & 2 deletions app/control_plane/lib/ankole/ai_gateway/providers/azure_openai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do

use Ankole.AIGateway.ProviderDSL

alias Ankole.AIGateway.OpenAIRequestOptions
alias Ankole.AIGateway.ProviderConnectionCheck
alias Ankole.AIGateway.ReasoningEffort
alias Ankole.AIGateway.UniversalAIRequest
Expand Down Expand Up @@ -33,10 +34,22 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do
scope: :request
)

setting(:reasoningSummary, scope: :request)
setting(:reasoningSummary,
type: :select,
options: OpenAIRequestOptions.reasoning_summary_values(),
scope: :request,
advanced: true
)

setting(:serviceTier, scope: :request, advanced: true)
setting(:strictJSONSchema, type: :boolean, scope: :request, advanced: true)
setting(:textVerbosity, scope: :request)

setting(:textVerbosity,
type: :select,
options: OpenAIRequestOptions.text_verbosity_values(),
scope: :request
)

setting(:truncation, scope: :request, advanced: true)

language_model do
Expand Down Expand Up @@ -65,6 +78,7 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do
)
|> put_auth(ctx)
|> ReasoningEffort.put_provider_options(ctx, target: target_for_endpoint(endpoint_mode))
|> OpenAIRequestOptions.put_provider_options(endpoint_target(endpoint_mode))
end
end

Expand Down Expand Up @@ -99,6 +113,9 @@ defmodule Ankole.AIGateway.Providers.AzureOpenAI do
defp target_for_endpoint("responses"), do: :reasoning
defp target_for_endpoint(_mode), do: :reasoning_effort

defp endpoint_target("responses"), do: :responses
defp endpoint_target(_mode), do: :chat_completions

# Azure deployments may use either bearer tokens or the legacy `api-key`
# header. A credential already prefixed with `Bearer ` is treated as bearer
# even when the stored auth scheme is not explicit.
Expand Down
20 changes: 18 additions & 2 deletions app/control_plane/lib/ankole/ai_gateway/providers/openai.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do

use Ankole.AIGateway.ProviderDSL

alias Ankole.AIGateway.OpenAIRequestOptions
alias Ankole.AIGateway.ProviderConnectionCheck
alias Ankole.AIGateway.ReasoningEffort
alias Ankole.AIGateway.UniversalAIRequest
Expand All @@ -28,12 +29,24 @@ defmodule Ankole.AIGateway.Providers.OpenAI do
scope: :request
)

setting(:reasoningSummary, scope: :request)
setting(:reasoningSummary,
type: :select,
options: OpenAIRequestOptions.reasoning_summary_values(),
scope: :request,
advanced: true
)

setting(:promptCacheKey, scope: :request, advanced: true)
setting(:promptCacheRetention, scope: :request, advanced: true)
setting(:serviceTier, scope: :request, advanced: true)
setting(:strictJSONSchema, type: :boolean, scope: :request, advanced: true)
setting(:textVerbosity, scope: :request)

setting(:textVerbosity,
type: :select,
options: OpenAIRequestOptions.text_verbosity_values(),
scope: :request
)

setting(:truncation, scope: :request, advanced: true)
setting(:systemMessageMode, scope: :request, advanced: true)
setting(:forceReasoning, type: :boolean, scope: :request, advanced: true)
Expand Down Expand Up @@ -67,6 +80,7 @@ defmodule Ankole.AIGateway.Providers.OpenAI do
|> openai_headers()
|> UniversalAIRequest.bearer_auth()
|> ReasoningEffort.put_provider_options(ctx, target: :reasoning)
|> OpenAIRequestOptions.put_provider_options(:responses)

_endpoint ->
prepare_sse_language_model(ctx)
Expand Down Expand Up @@ -104,13 +118,15 @@ defmodule Ankole.AIGateway.Providers.OpenAI do
|> openai_headers()
|> UniversalAIRequest.bearer_auth()
|> ReasoningEffort.put_provider_options(ctx, target: :reasoning_effort)
|> OpenAIRequestOptions.put_provider_options(:chat_completions)

_endpoint_kind ->
ctx
|> UniversalAIRequest.new("responses", :openai_responses)
|> openai_headers()
|> UniversalAIRequest.bearer_auth()
|> ReasoningEffort.put_provider_options(ctx, target: :reasoning)
|> OpenAIRequestOptions.put_provider_options(:responses)
end
end

Expand Down
77 changes: 7 additions & 70 deletions app/control_plane/lib/ankole/identity_providers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ defmodule Ankole.IdentityProviders do
alias Ankole.IdentityProviders.Jobs.SyncProvider
alias Ankole.Logging
alias Ankole.Plugins
alias Ankole.Plugins.ConfigSecrets

@adapter_contract_id "principals.identity_provider"
@credential_check_capability "credential_check"
@directory_full_sync_capability "directory_full_sync"
@directory_realtime_sync_capability "directory_realtime_sync"
@secret_mask "********"
@legacy_secret_mask "********"

@type adapter :: %{
adapter_id: String.t(),
Expand Down Expand Up @@ -691,22 +692,25 @@ defmodule Ankole.IdentityProviders do
defp provider_projection(provider) do
with {:ok, adapter} <- fetch_adapter(provider["adapter_id"]),
{:ok, config} <- provider_config(provider) do
{config, stored_secret_paths} = ConfigSecrets.redact(adapter.fields, config)

{:ok,
%{
"provider_id" => provider["provider_id"],
"adapter_id" => provider["adapter_id"],
"plugin_id" => provider["plugin_id"],
"config_key" => provider["config_key"],
"enabled" => provider["enabled"] != false,
"config" => mask_encrypted_config(adapter, config)
"config" => config,
"stored_secret_paths" => stored_secret_paths
}}
end
end

defp provider_config_for_write(adapter, config_key, config) do
case AppConfigure.get_by_key(config_key) do
{:ok, existing} when is_map(existing) ->
{:ok, preserve_encrypted_config(adapter, config, existing)}
{:ok, ConfigSecrets.preserve(adapter.fields, config, existing, [@legacy_secret_mask])}

:error ->
{:ok, config}
Expand All @@ -716,73 +720,6 @@ defmodule Ankole.IdentityProviders do
end
end

defp preserve_encrypted_config(adapter, config, existing) do
adapter
|> encrypted_field_paths()
|> Enum.reduce(config, fn path, acc ->
case {secret_placeholder?(get_path(acc, path)), get_path(existing, path)} do
{true, value} when not is_nil(value) -> put_path(acc, path, value)
_value -> acc
end
end)
end

defp mask_encrypted_config(adapter, config) do
adapter
|> encrypted_field_paths()
|> Enum.reduce(config, fn path, acc ->
case is_nil(get_path(acc, path)) do
true -> acc
false -> put_path(acc, path, @secret_mask)
end
end)
end

defp encrypted_field_paths(%{fields: fields}), do: encrypted_field_paths(fields)

defp encrypted_field_paths(fields) when is_list(fields) do
fields
|> Enum.filter(&(value(&1, :encrypted) == true))
|> Enum.map(&value(&1, :path))
|> Enum.filter(&is_binary/1)
end

defp encrypted_field_paths(_fields), do: []

defp secret_placeholder?(nil), do: true
defp secret_placeholder?(""), do: true
defp secret_placeholder?(@secret_mask), do: true
defp secret_placeholder?(_value), do: false

defp get_path(source, path) when is_map(source) and is_binary(path) do
path
|> String.split(".")
|> Enum.reduce_while(source, fn segment, value ->
case value do
value when is_map(value) -> {:cont, Map.get(value, segment)}
_value -> {:halt, nil}
end
end)
end

defp get_path(_source, _path), do: nil

defp put_path(source, path, value) when is_map(source) and is_binary(path) do
do_put_path(source, String.split(path, "."), value)
end

defp do_put_path(source, [segment], value), do: Map.put(source, segment, value)

defp do_put_path(source, [segment | rest], value) do
child =
case Map.get(source, segment) do
child when is_map(child) -> child
_value -> %{}
end

Map.put(source, segment, do_put_path(child, rest, value))
end

defp sync_reason(value) when is_atom(value), do: Atom.to_string(value)
defp sync_reason(value) when is_binary(value), do: value
defp sync_reason(value), do: inspect(value)
Expand Down
Loading