The setting-name branch for /ai set custom never fired: it passed the
prefix "/ai set custom " with a trailing space to
autocomplete_param_with_ac(), which appends a space itself, so the
matched prefix contained a double space no real input ever has. Even
without that, the primitive completes the first token after the command
while the setting name is the second, and the num_args guard only held
before the name was started. The suggestion list (tools/search/memory/
plugins) predated the /ai set custom backend and matched no real
payload parameter.
Replace the branch with token-position completion (arg 5 via
autocomplete_param_no_with_func) fed by a list rebuilt on demand from
common payload parameters plus the keys already set on the provider,
exposed through the new ai_get_provider_setting_keys() (snapshot taken
under settings_lock). Drop the now-unused parse_args() call whose NULL
result was fed to g_strv_length() unguarded, spamming a glib CRITICAL
on TAB at /ai with zero or five-plus arguments.
Drive both OpenAI-compatible flavours from one code path. Requests
default to /v1/responses and fall back once to /v1/chat/completions when
the provider reports the endpoint missing (404/405/501) or rejects the
payload shape (400/422); the working flavour is cached for the rest of
the run as a first-attempt hint, with the other flavour kept as a
fallback so a backend change self-corrects in-request. A per-provider
override is available via
/ai set api-type <provider> responses|chat-completions|auto
and persisted as api_type= in the provider section.
One payload builder serves both flavours (messages/stream vs
input/stream/store); history and custom settings are serialized once per
request and the per-flavour envelope is assembled per attempt under a
single lock acquisition, so the fallback retry cannot double-count the
prompt. ai_parse_response_typed() dispatches on the request flavour and
splits extraction per envelope: chat completions anchored on
choices[].message.content, responses on the output_text part with a
string-aware backward scan bounded to that part's own object so a
reasoning summary or a truncated body is never returned as the reply.
Endpoint detection classifies wrong-model errors from the structured
error code/type/message when present, so route-missing 404s from
gateways no longer suppress the fallback; it preserves and surfaces the
first payload-rejection error when the fallback also fails, and re-probes
on an unparseable 2xx. resolved_api_type is written only through a locked
helper guarded by a URL/api-type epoch, and api_url is snapshotted under
settings_lock in the request and models-fetch threads, closing
use-after-free and stale-cache races against /ai set provider. The
models-fetch provider ref is taken on the main thread and released on
every worker exit path. Reserved custom-setting keys are extended with
input (store stays writable as a legitimate chat-completions parameter).
The chat-completions refactor removed the legacy Perplexity "text"
extraction but left two tests asserting the old behavior, breaking
make check on every CI flavor:
- test_ai_parse_response_perplexity_text expected the legacy /v1/agent
nested format to parse; it now yields NULL — renamed to
test_ai_parse_response_legacy_text_format_unsupported.
- test_ai_parse_response_text_preferred_over_content expected "text"
to win; "content" is authoritative now — renamed to
test_ai_parse_response_content_preferred_over_text.
Add test_ai_set_provider_setting_reserved_key covering the new
reserved-key rejection.
Add an AI client module that integrates with OpenAI-compatible API
providers (OpenAI, Perplexity, and custom endpoints) to provide
AI-assisted chat within CProof. Users can start sessions with /ai start,
send prompts, receive responses in a dedicated AI window, switch between
providers and models, and manage API keys — all with tab-completion.
Providers are configured via /ai set commands with per-provider API keys,
endpoints, default models, and custom settings. Two default providers
(openai, perplexity) are seeded on first use. Provider state persists in
[ai/<name>] sections of the preferences keyfile with automatic migration
from the previous flat-key format.
The /ai command integrates into the existing command system with 8
subcommands covering provider management, session lifecycle, model
fetching, and conversation clearing. Autocomplete uses the standard
flat prefix-matching chain for reliable tab-completion at every nesting
level. A new ProfAiWin window type is added to the window system.
Architecture:
Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls
Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call
Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s)
Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training
Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM
JSON parsing uses unified helpers for both chat responses and error
envelopes with consistent escape decoding. The response parser tries
Perplexity /v1/responses "text" field first, then falls back to OpenAI
"content". Error parsing extracts provider error.message from the
standard envelope format. Model parsing handles multiple API response
formats (OpenAI list, Perplexity, array) including edge cases.
Tests include 470+ lines of unit tests covering provider management,
session lifecycle, JSON parsing (multiple formats), autocomplete cycling,
and error handling, plus functional tests for /ai command dispatch.
A stub_ai.c module isolates unit tests from UI dependencies.