Skip to content
Open
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
12 changes: 10 additions & 2 deletions lib/ourocode/command/registry/builtin.ex
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,21 @@ defmodule Ourocode.Command.Registry.Builtin do
summary: "Show plugin readiness and reload guidance.",
run_spec: %{kind: :builtin_action, action: :show_config}
},
%{
name: "provider",
slash: "/provider",
aliases: ["/providers"],
category: :runtime,
summary: "Pick the active main-session provider/backend.",
run_spec: %{kind: :builtin_action, action: :select_provider}
},
%{
name: "model",
slash: "/model",
aliases: ["/models"],
category: :runtime,
summary: "Pick the active main-session backend (detected models).",
run_spec: %{kind: :builtin_action, action: :select_model}
summary: "Show provider-specific model commands and slug selection status.",
run_spec: %{kind: :builtin_action, action: :show_model_commands}
},
%{
name: "theme",
Expand Down
36 changes: 33 additions & 3 deletions lib/ourocode/model/catalog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ defmodule Ourocode.Model.Catalog do
@spec list(keyword()) :: [Model.t()]
def list(opts \\ []) do
which = Keyword.get(opts, :which, &System.find_executable/1)
cli_stream = Keyword.get(opts, :cli_stream, &Ourocode.Model.Cli.stream/4)
codex_signed_in? = Keyword.get_lazy(opts, :codex_signed_in, &Codex.signed_in?/0)
anthropic_signed_in? = Keyword.get_lazy(opts, :anthropic_signed_in, &Anthropic.signed_in?/0)

[codex_model(codex_signed_in?), claude_api_model(anthropic_signed_in?) | cli_models(which)]
[
codex_model(codex_signed_in?),
claude_api_model(anthropic_signed_in?)
| cli_models(which, cli_stream)
]
end

@doc """
Expand Down Expand Up @@ -60,6 +65,31 @@ defmodule Ourocode.Model.Catalog do
@spec selectable([Model.t()]) :: [Model.t()]
def selectable(models), do: Enum.reject(models, &(&1.status == :unavailable))

@doc "Provider-specific model slugs available for a backend provider."
@spec provider_model_slugs(atom()) :: [map()]
defdelegate provider_model_slugs(provider_id), to: Ourocode.Model.ProviderModels

@doc "Built-in default model slug for a backend provider."
@spec default_provider_model_slug(atom()) :: String.t() | nil
defdelegate default_provider_model_slug(provider_id), to: Ourocode.Model.ProviderModels

@doc "Looks up a provider-specific model slug after normalizing user input."
@spec fetch_provider_model_slug(atom(), term()) :: map() | nil
defdelegate fetch_provider_model_slug(provider_id, slug), to: Ourocode.Model.ProviderModels

@doc """
Validates a provider-specific model slug.

Built-in slugs must be present in `provider_model_slugs/1`. Tests and future
provider migration paths may pass `allow_custom?: true` to store a nonblank
slug without making it the global default or adding it to the catalog.
"""
@spec validate_provider_model_slug(atom(), term(), keyword()) ::
{:ok, String.t()}
| {:error, :unknown_provider | :invalid_slug | :blank_slug | :unknown_slug}
defdelegate validate_provider_model_slug(provider_id, slug, opts \\ []),
to: Ourocode.Model.ProviderModels

defp codex_model(signed_in?) do
%Model{
id: :codex,
Expand Down Expand Up @@ -107,7 +137,7 @@ defmodule Ourocode.Model.Catalog do
}
end

defp cli_models(which) do
defp cli_models(which, cli_stream) do
Ourocode.Model.Cli.specs()
|> Map.keys()
|> Enum.sort()
Expand All @@ -128,7 +158,7 @@ defmodule Ourocode.Model.Catalog do
_none -> prompt
end

Ourocode.Model.Cli.stream(id, prompt, Keyword.put(opts, :which, which), on_chunk)
cli_stream.(id, prompt, Keyword.put(opts, :which, which), on_chunk)
end
}
end)
Expand Down
33 changes: 23 additions & 10 deletions lib/ourocode/model/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do
end
end

@doc false
@spec runner_command(
String.t(),
[String.t()],
{:unix | :win32, atom()},
(String.t() -> String.t() | nil)
) :: {String.t(), [String.t()]}
def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1)

def runner_command(path, args, {:win32, _name}, _which), do: {path, args}

def runner_command(path, args, _os_type, which) when is_function(which, 1) do
{which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@" </dev/null), path | args]}
end

@doc """
Runs the CLI for one prompt, invoking `on_chunk` for each stdout chunk.

Expand All @@ -47,6 +62,7 @@ defmodule Ourocode.Model.Cli do
{:ok, String.t()} | {:error, term()}
def stream(id, prompt, opts, on_chunk) when is_binary(prompt) and is_function(on_chunk, 1) do
which = Keyword.get(opts, :which, &System.find_executable/1)
run = Keyword.get(opts, :run, &run/4)
bin = Map.fetch!(@bins, id)

case which.(bin) do
Expand All @@ -55,23 +71,23 @@ defmodule Ourocode.Model.Cli do

path ->
delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms)
run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1)
run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run)
end
end

defp run_with_retry(id, path, args, on_chunk, delay, attempt) do
defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do
emitted = :counters.new(1, [])

counted_chunk = fn chunk ->
:counters.add(emitted, 1, 1)
on_chunk.(chunk)
end

case run(id, path, args, counted_chunk) do
case run.(id, path, args, counted_chunk) do
{:error, {:exit, _status}} = error ->
if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do
Process.sleep(delay * Integer.pow(2, attempt - 1))
run_with_retry(id, path, args, on_chunk, delay, attempt + 1)
run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run)
else
error
end
Expand All @@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do
end

defp run(id, path, args, on_chunk) do
# Spawn through `sh -c 'exec "$0" "$@" </dev/null'` so the CLI's stdin is
# /dev/null, not the BEAM port pipe. Args are passed positionally, so the
# prompt needs no shell escaping.
shell = System.find_executable("sh") || "/bin/sh"
{command, command_args} = runner_command(path, args)

port =
Port.open({:spawn_executable, shell}, [
Port.open({:spawn_executable, command}, [
:binary,
:exit_status,
:hide,
:stderr_to_stdout,
args: ["-c", ~s(exec "$0" "$@" </dev/null), path | args]
args: command_args
])

collect(id, port, [], "", on_chunk)
Expand Down
23 changes: 15 additions & 8 deletions lib/ourocode/model/conversation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,19 @@ defmodule Ourocode.Model.Conversation do

transcript =
Enum.map_join(turns, "\n\n", fn %{user: user, assistant: assistant} ->
"user: #{user}\nassistant: #{assistant}"
"user: #{normalize_newlines(user)}\nassistant: #{normalize_newlines(assistant)}"
end)

elision = if elided > 0, do: "[#{elided} earlier turn(s) elided]\n\n", else: ""

"""
## Conversation so far (replayed by the ourocode harness)
#{elision}#{transcript}

## Current message
#{prompt}
"""
IO.iodata_to_binary([
"## Conversation so far (replayed by the ourocode harness)\n",
elision,
transcript,
"\n\n## Current message\n",
normalize_newlines(prompt),
"\n"
])
end

@doc """
Expand Down Expand Up @@ -148,6 +149,12 @@ defmodule Ourocode.Model.Conversation do
utf8_prefix(text, @turn_cap_bytes) <> "\n...[truncated]"
end

defp normalize_newlines(text) do
text
|> String.replace("\r\n", "\n")
|> String.replace("\r", "\n")
end

# A byte cut lands at most 3 bytes inside a UTF-8 sequence; after that the
# content was not valid UTF-8 to begin with and is kept as cut.
defp utf8_prefix(bin, limit), do: trim_invalid(binary_part(bin, 0, limit), 3)
Expand Down
9 changes: 8 additions & 1 deletion lib/ourocode/model/profile.ex
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,20 @@ defmodule Ourocode.Model.Profile do
defp runtime_name(_label, model_label), do: short_model_label(model_label)

defp pick_model(models, candidates, active_model, opts) do
ready_candidate(models, candidates) ||
ready_active_candidate(active_model, candidates) ||
ready_candidate(models, candidates) ||
ready_active(active_model) ||
selectable_candidate(models, candidates) ||
active_model(active_model) ||
Keyword.get_lazy(opts, :default_model, &Catalog.default/0)
end

defp ready_active_candidate(%Model{id: id} = model, candidates) do
if id in candidates and Model.ready?(model), do: model
end

defp ready_active_candidate(_model, _candidates), do: nil

defp ready_candidate(models, candidates) do
Enum.find_value(candidates, fn id ->
case Catalog.fetch(models, id) do
Expand Down
90 changes: 90 additions & 0 deletions lib/ourocode/model/provider_models.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
defmodule Ourocode.Model.ProviderModels do
@moduledoc """
Provider-specific model slug catalog.

Backend providers and provider model slugs are separate UI choices. This
module keeps slug validation out of the backend discovery catalog.
"""

@codex_model_slugs [
%{provider_id: :codex, slug: "gpt-5.5", label: "gpt-5.5", default?: true},
%{provider_id: :codex, slug: "gpt-5.3-codex", label: "gpt-5.3-codex", default?: false}
]

@provider_model_slugs %{codex: @codex_model_slugs}

@doc "Provider-specific model slugs available for a backend provider."
@spec provider_model_slugs(atom()) :: [map()]
def provider_model_slugs(provider_id) when is_atom(provider_id),
do: Map.get(@provider_model_slugs, provider_id, [])

def provider_model_slugs(_provider_id), do: []

@doc "Built-in default model slug for a backend provider."
@spec default_provider_model_slug(atom()) :: String.t() | nil
def default_provider_model_slug(provider_id) when is_atom(provider_id) do
provider_id
|> provider_model_slugs()
|> Enum.find(&Map.get(&1, :default?, false))
|> case do
%{slug: slug} -> slug
nil -> nil
end
end

def default_provider_model_slug(_provider_id), do: nil

@doc "Looks up a provider-specific model slug after normalizing user input."
@spec fetch_provider_model_slug(atom(), term()) :: map() | nil
def fetch_provider_model_slug(provider_id, slug) when is_atom(provider_id) do
with {:ok, normalized} <- normalize_provider_model_slug(slug) do
Enum.find(provider_model_slugs(provider_id), &(&1.slug == normalized))
else
_error -> nil
end
end

def fetch_provider_model_slug(_provider_id, _slug), do: nil

@doc """
Validates a provider-specific model slug.

Built-in slugs must be present in `provider_model_slugs/1`. Tests and future
provider migration paths may pass `allow_custom?: true` to store a nonblank
slug without making it the global default or adding it to the catalog.
"""
@spec validate_provider_model_slug(atom(), term(), keyword()) ::
{:ok, String.t()}
| {:error, :unknown_provider | :invalid_slug | :blank_slug | :unknown_slug}
def validate_provider_model_slug(provider_id, slug, opts \\ [])

def validate_provider_model_slug(provider_id, slug, opts) when is_atom(provider_id) do
with {:provider, [_ | _]} <- {:provider, provider_model_slugs(provider_id)},
{:ok, normalized} <- normalize_provider_model_slug(slug) do
cond do
fetch_provider_model_slug(provider_id, normalized) ->
{:ok, normalized}

Keyword.get(opts, :allow_custom?, false) ->
{:ok, normalized}

true ->
{:error, :unknown_slug}
end
else
{:provider, []} -> {:error, :unknown_provider}
{:error, reason} -> {:error, reason}
end
end

def validate_provider_model_slug(_provider_id, _slug, _opts), do: {:error, :unknown_provider}

defp normalize_provider_model_slug(slug) when is_binary(slug) do
case String.trim(slug) do
"" -> {:error, :blank_slug}
normalized -> {:ok, normalized}
end
end

defp normalize_provider_model_slug(_slug), do: {:error, :invalid_slug}
end
Loading