Commit Diff


commit - 013bbef42945efe028e39007e2c4b85909c42092
commit + d140bf3b9088768c6532da38f50345ebb99d2378
blob - 789d2e7baac27673436064c8a4bb921bb95b87c4
blob + d14ac8deda24ca7dec52894d25ddaf79726f713e
--- docs/design/m7-curses.md
+++ docs/design/m7-curses.md
@@ -231,8 +231,11 @@ The remaining ยง2.1 surfaces landed in the M7 follow-u
   simply closes the drawer.
 - **The picker** (`/model`): fugu-api holds every configured
   provider's endpoint and key (PROV after HELLO) and fans a bounded
-  GET /v1/models across them; ids stream back tagged by provider
-  slot, relayed to the overlay (type-to-fuzzy-filter, Enter selects).
+  models GET across them. Direct Providers with a standard Generation-path
+  suffix preserve its configured prefix (`/api/v1/chat/completions` discovers
+  at `/api/v1/models`); unusual paths retain `/v1/models`. Ids stream back
+  tagged by provider slot, relayed to the overlay (type-to-fuzzy-filter,
+  Enter selects).
   Selection makes the provider active (SET_PROVIDER), swaps the
   model, cold-starts the cache, and re-sends UI_CONFIG.  A provider
   that fails contributes nothing.  The openai dialect's request codec
blob - 3c7b71d199e9844692f20421304a55010fdd8b14
blob + 5e35973f8ce0f8d89e87a7f788867f2a3476e377
--- etc/fugu.conf.sample
+++ etc/fugu.conf.sample
@@ -48,8 +48,10 @@
 
 # Direct-API endpoint overrides; the defaults follow the provider type
 # (api.anthropic.com or api.openai.com, port 443, /v1/messages or
-# /v1/chat/completions).  TLS only.  Subscription endpoints are fixed and
-# reject every endpoint override.
+# /v1/chat/completions).  For those standard suffixes, /model discovers at
+# the sibling /models path under the same prefix; for example,
+# /api/v1/chat/completions uses /api/v1/models.  TLS only.  Subscription
+# endpoints are fixed and reject every endpoint override.
 #api_host "api.anthropic.com"
 #api_port 443
 #api_path "/v1/messages"
blob - 156955f32d3f03a8c55b72479053527f54b833fa
blob + cf2924fc7f77e706f29f4f9754b28659901a1bc3
--- handoff/behavior.md
+++ handoff/behavior.md
@@ -667,7 +667,7 @@ Keywords (global or in `match` unless noted). Defaults
 | `ascii` yes\|no | ASCII-transliterated interface [no] |
 | `api_host` *string* | endpoint host [api.anthropic.com / api.openai.com] |
 | `api_port` *string* | endpoint port [443]; TLS only |
-| `api_path` *string* | endpoint path [/v1/messages or /v1/chat/completions] |
+| `api_path` *string* | endpoint path [/v1/messages or /v1/chat/completions]; standard suffixes preserve their prefix for sibling /models discovery |
 | `api_key` *string* | model API key (secret); `anthropic_key` is a legacy synonym for the anthropic provider, `api_key` wins if both |
 | `api_key_file` *string* | file whose first line is the key (secret; secrecy-checked); `anthropic_key_file` legacy synonym |
 | `oauth_token` *string* | personal subscription access token stored directly in the protected configuration; valid only for a `claude` or `codex` route |
blob - 6ce6fbd248b4ec24efb550d290158c225e01152a
blob + 4d2a2af3a02467f06c7b1e904286a8bfa3abb2ca
--- regress/turn/run.sh
+++ regress/turn/run.sh
@@ -2250,6 +2250,30 @@ if [ -x "$obj/ptydrive" ]; then
 	ok "picker: selected provider metadata drove the context window" $?
 	stop_stub
 
+	# A compatible provider mounted below a path prefix discovers models
+	# beside its Chat Completions endpoint, not at the OpenAI default root.
+	rm -rf "$HOME/.fugu"
+	rm -f "$dir/reqout"
+	MODELS_PATH=/api/v1/models start_stub "OAI_OK OAI_OK"
+	cat > "$dir/fugu.conf" <<EOF
+model "openrouter/default"
+provider "openai"
+api_host "127.0.0.1"
+api_port "$stubport"
+api_path "/api/v1/chat/completions"
+api_key "$KEY"
+EOF
+	printf '/model\r\000\000model-b\r\000hello\r\000/quit\r' | \
+	    TERM=vt100 HOME=$HOME \
+	    FUGU_CONF=$dir/fugu.conf FUGU_LIBEXEC=$dir/libexec/fugu \
+	    FUGU_CA_FILE=$dir/cert.pem FUGU_RETRY_BASE_MS=50 \
+	    "$obj/ptydrive" "$fugu" >"$dir/pickerout" 2>&1
+	rc=$?
+	ok "prefixed picker: clean exit" $([ "$rc" -eq 0 ]; echo $?)
+	grep -q '"model":"stub-model-b"' "$dir/reqout" 2>/dev/null
+	ok "prefixed picker: models path follows the configured API prefix" $?
+	stop_stub
+
 	# Request a listing and immediately submit a slow-tool prompt; delayed
 	# selection must land between its two Generations.  The selection cools
 	# the Projection cursor but must not terminate the Turn transaction.
blob - 45c12b71a2088f40f83ca91b6dd43a40fc6122f5
blob + 62d4ec7d88e83b7cf6a0f8b16c2a92d83638fba6
--- regress/turn/stub.c
+++ regress/turn/stub.c
@@ -1563,7 +1563,7 @@ main(int argc, char *argv[])
 	socklen_t		 slen;
 	const char		*cert = NULL, *key = NULL, *keyout, *reqout,
 					*reqlog, *overlapout, *poolout,
-					*weborderout, *futurepath;
+					*weborderout, *futurepath, *models_path;
 	char			*req;
 	char			 web_first = '?', web_second = '?';
 	ssize_t			 reqlen;
@@ -1594,6 +1594,9 @@ main(int argc, char *argv[])
 	poolout = getenv("POOLOUT");
 	weborderout = getenv("WEBORDEROUT");
 	futurepath = getenv("FUTUREPATH");
+	models_path = getenv("MODELS_PATH");
+	if (models_path == NULL || models_path[0] == '\0')
+		models_path = "/v1/models";
 	if ((req = calloc(1, STUB_REQ_MAX)) == NULL)
 		errx(1, "request fixture allocation");
 
@@ -1692,7 +1695,10 @@ main(int argc, char *argv[])
 				i--;	/* web origin call consumes no API scenario */
 				continue;
 			}
-			if (strncmp(req, "GET /v1/models", 14) == 0) {
+			if (strncmp(req, "GET ", 4) == 0 &&
+			    strncmp(req + 4, models_path,
+			    strlen(models_path)) == 0 &&
+			    req[4 + strlen(models_path)] == ' ') {
 				/* the picker's live listing (behavior.md
 				 * 2.1): fixed ids, no scenario consumed */
 				static const char models[] =
blob - c67bb9e8f4d39179fe5b07178ccb7244c97a758c
blob + 7ba1ba3fb769cb0011587f58c6cb41eef96caec0
--- src/fugu/fugu.1
+++ src/fugu/fugu.1
@@ -335,6 +335,8 @@ Automatic Compaction uses the same operation; see
 .Xr fugu.conf 5 .
 .It Cm /model
 Open the curses model picker.
+For direct Providers, discovery preserves the configured API path prefix when
+the Generation endpoint uses the protocol's standard suffix.
 .It Cm /context
 Print byte-derived token estimates for the current projection and its
 largest items.
blob - 0db78dfda50a0163b1410ae6bc79db67623c31b5
blob + 6350f5ca3ebe2d68545ba631deb2ee9b08848349
--- src/fugu/fugu.conf.5
+++ src/fugu/fugu.conf.5
@@ -256,6 +256,21 @@ and
 .Pa api.openai.com
 for
 .Cm openai .
+When the configured path ends in the Provider protocol's standard
+.Pa /messages
+or
+.Pa /chat/completions
+suffix, model discovery preserves the preceding path prefix and uses its
+sibling
+.Pa /models
+path.
+For example,
+.Pa /api/v1/chat/completions
+discovers models from
+.Pa /api/v1/models .
+An unusual path with neither standard suffix retains the historical
+.Pa /v1/models
+discovery path.
 This directive is rejected when the implicit Provider is
 .Cm claude
 or
@@ -667,7 +682,8 @@ or
 .Cm codex .
 .It Cm api_path Ar path
 Set the request path, with the same per-type defaults as the implicit
-provider.
+provider, including prefix-preserving model discovery for a standard request
+suffix.
 This directive is rejected for
 .Cm claude
 or
blob - f598c1cedcccf122a2df503ecf106cd991ba3377
blob + f54edc40b4d0cf78e89aa4be449a2794301b3bdd
--- src/fugu-api/main.c
+++ src/fugu-api/main.c
@@ -1959,6 +1959,43 @@ request_new(struct worker *w, uint32_t id)
 }
 
 /*
+ * Direct Providers commonly mount their whole API below a path prefix.
+ * Preserve that prefix when moving from the typed Generation endpoint to
+ * model discovery.  An unusual endpoint that does not use the protocol's
+ * standard suffix retains the historical fallback.
+ */
+static int
+provider_models_path(const struct prov *v, char *out, size_t outsz)
+{
+	const char	*suffix;
+	size_t		 pathlen, prefixlen, suffixlen;
+
+	switch (v->type) {
+	case PROVIDER_OPENAI:
+		suffix = "/chat/completions";
+		break;
+	case PROVIDER_ANTHROPIC:
+		suffix = "/messages";
+		break;
+	default:
+		return (-1);
+	}
+	pathlen = strcspn(v->path, "?");
+	suffixlen = strlen(suffix);
+	if (pathlen >= suffixlen &&
+	    memcmp(v->path + pathlen - suffixlen, suffix, suffixlen) == 0) {
+		prefixlen = pathlen - suffixlen;
+		if (outsz < sizeof("/models") ||
+		    prefixlen > outsz - sizeof("/models"))
+			return (-1);
+		memcpy(out, v->path, prefixlen);
+		(void)strlcpy(out + prefixlen, "/models", outsz - prefixlen);
+		return (0);
+	}
+	return (strlcpy(out, "/v1/models", outsz) >= outsz ? -1 : 0);
+}
+
+/*
  * One provider's model listing (behavior.md 2.1, 8): GET the per-type
  * models endpoint and stream each id back tagged with the slot.  The
  * body is hostile (T3): size-capped, ids bounded and NUL-free, at
@@ -1972,6 +2009,7 @@ list_one(struct worker *w, struct api *a, int slot)
 	struct buf	 body;
 	struct json	 j;
 	char		 headers[API_AUTH_HDR_MAX];
+	char		 models_path[FUGU_API_PATH_MAX + 1];
 	char		 tmp[8192];
 	ssize_t		 n;
 	int		 data, i, root, nmodels, rc, sent = 0;
@@ -2000,6 +2038,8 @@ list_one(struct worker *w, struct api *a, int slot)
 		buf_free(&e);
 		return;
 	}
+	if (provider_models_path(v, models_path, sizeof(models_path)) == -1)
+		return;
 	{
 		struct api_request fake;
 
@@ -2017,7 +2057,7 @@ list_one(struct worker *w, struct api *a, int slot)
 		http_close(&h);
 		return;
 	}
-	rc = http_request(&h, "GET", "/v1/models", headers, NULL, 0,
+	rc = http_request(&h, "GET", models_path, headers, NULL, 0,
 	    LIST_TIMEOUT);
 	explicit_bzero(headers, sizeof(headers));
 	if (rc == -1 || http_response(&h, LIST_TIMEOUT) == -1 ||