Files
claude-agent-acp/src/gateway-models.ts
Sergey Shkredov e8eb9d340f feat: narrow models to what the providers/set gateway lists (#1340)
### Summary

Addresses [IJAI-1783](https://youtrack.jetbrains.com/issue/IJAI-1783).

An organization can restrict the models its JetBrains gateway serves,
but a session routed through `providers/set` offered the whole Claude
Code catalogue, so the picker showed models the gateway then refused.
`providers/set` now lists the gateway's models before it responds, and
the model picker offers only the SDK models the gateway lists.

- `anthropic`: `GET {baseUrl}/v1/models?limit=1000`, paginated by
`has_more`/`last_id`, with the provider headers and `anthropic-version`.
- `bedrock`: `GET {baseUrl}/foundation-models` (`ListFoundationModels`,
`modelSummaries[].modelId`), the listing the JetBrains gateway answers
on its Bedrock route. Only `anthropic.` ids count. The cross-region
prefix `global` when listed, else that of the first listed id, becomes
the session's `ANTHROPIC_BEDROCK_REGION_PREFIX`, so the CLI calls the
models under the prefix the gateway serves; ids of other prefixes, and
SDK rows resolved to another prefix, are dropped.
- Rows match by `resolvedModel` (or `value`) without dates, `-vN:M`
suffixes, `[1m]` hints and provider prefixes; the narrowing runs after
the `availableModels` allowlist. When the gateway does not list the
model a session runs (the dropped default, or a resumed or recreated
session's last model), the session switches to the first listed one with
`setModel`, so the CLI does not keep running an unlisted model.
- The listing runs in the provider-update queue, so `providers/set` and
`providers/disable` apply in the order they arrive; logout also clears a
`providers/set` that is still listing.
- A 4xx answer means the gateway has no listing: `providers/set`
succeeds and nothing is narrowed. Any other failed listing (network
error, 5xx, unexpected body, 10 s timeout) fails `providers/set` with
`internal_error`, and the previous provider stays in effect. An invalid
header or a base URL with credentials fails without repeating the value.
A listing that matches no SDK model leaves the session with no model to
offer: there is no model config option.
- `vertex`, gateways set through `authenticate`, and native routing are
not listed. Setting the same gateway again reuses its listing;
`providers/disable` and logout drop it.
- `docs/model-configuration.md` describes the narrowing.

### Not covered

Background haiku requests, subagents, and a `/model` typed into the
prompt choose their own model and are not checked against the listing.

### Verification

```bash
npm run check
npm run build
npm run test:run -- --no-file-parallelism   # 2964 passed, 5 failed, 30 skipped; all 5 in session-index.test.ts, flaky on main too
```

Tests stub `fetch` (`src/tests/setup-no-network.ts` answers an empty
listing by default) and cover the listing requests and pagination, the
Bedrock region prefix and its env, region-aware matching, the dropped
default, the failing `providers/set`, and the empty picker. The
JetBrains Bedrock gateway's `/foundation-models` answers
`global.anthropic.claude-{haiku,opus,sonnet}-5-5` (seen through Junie,
which lists the same route); this adapter has not yet been run end to
end against it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:03:27 +02:00

224 lines
8.5 KiB
TypeScript

/**
* Model discovery for a gateway set via `providers/set`.
*
* An organization can restrict which models its gateway serves, and the
* gateway's listing returns only those: `GET /v1/models` for an `anthropic`
* gateway, `GET /foundation-models` (Bedrock `ListFoundationModels`) for a
* `bedrock` one. The adapter lists them once when the provider is set, and the
* model picker then offers only the SDK models the gateway listed (see
* `narrowToGatewayModels` in `session-model.ts`). A listing that fails fails
* `providers/set` with a {@link GatewayListingError}.
*/
const LISTING_TIMEOUT_MS = 10_000;
const ANTHROPIC_PAGE_LIMIT = "1000";
const MAX_LISTING_PAGES = 20;
const MAX_LOGGED_BODY_CHARS = 500;
const ANTHROPIC_VERSION = "2023-06-01";
/**
* The bearer token `createEnvForProvider` sets as `ANTHROPIC_AUTH_TOKEN` and
* `AWS_BEARER_TOKEN_BEDROCK`, so the CLI sends `Authorization: Bearer acp-proxy`
* unless the provider headers replace it. The listing sends the same, so the
* gateway sees the same auth as on chat requests.
*/
export const PROVIDER_AUTH_TOKEN_PLACEHOLDER = "acp-proxy";
/**
* The cross-region inference profile prefixes the CLI knows:
* `eu.anthropic.claude-sonnet-5`. `ANTHROPIC_BEDROCK_REGION_PREFIX` accepts all
* of them but `us-gov`, which the CLI takes only from a `us-gov-*` AWS region;
* a gateway listing only `us-gov.*` ids would then get an empty picker. That
* case is theoretical, so it is left as is.
*/
const BEDROCK_REGION_PREFIXES = ["us", "eu", "apac", "jp", "au", "us-gov", "global"];
type ListingLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
type ListingPage = { ids: string[]; nextQuery?: Record<string, string> };
/**
* A gateway listing that failed: a network error, a status other than 2xx or
* 4xx, a body that is not the expected listing, or the 10 s timeout. The
* message names the request path and the reason, never a header value.
*/
export class GatewayListingError extends Error {
override name = "GatewayListingError";
}
/**
* Lists the model ids an `anthropic` gateway at `baseUrl` serves, following
* `has_more` / `last_id` pagination. Returns `undefined` when the gateway
* answers 4xx: it has no listing, and nothing is narrowed. Throws a
* {@link GatewayListingError} when the listing fails. Header values are never
* logged.
*/
export async function listGatewayModels(
baseUrl: string,
providerHeaders: Record<string, string>,
logger: ListingLogger,
): Promise<string[] | undefined> {
return listPages(
`${baseUrl.replace(/\/+$/, "")}/v1/models`,
{ limit: ANTHROPIC_PAGE_LIMIT },
listingHeaders(providerHeaders, { "anthropic-version": ANTHROPIC_VERSION }),
logger,
(body) => {
const page = body as { data?: unknown; has_more?: unknown; last_id?: unknown };
if (!Array.isArray(page.data)) return undefined;
return {
ids: stringField(page.data, "id"),
nextQuery:
page.has_more === true && typeof page.last_id === "string"
? { limit: ANTHROPIC_PAGE_LIMIT, after_id: page.last_id }
: undefined,
};
},
);
}
/**
* Lists the Anthropic models a `bedrock` gateway at `baseUrl` serves
* (`GET /foundation-models`, Bedrock `ListFoundationModels`, one page), the
* listing AI Router answers on its Bedrock route. Returns the listed ids, such
* as `global.anthropic.claude-sonnet-5-5`; `undefined` and the errors are
* those of {@link listGatewayModels}.
*/
export async function listBedrockModels(
baseUrl: string,
providerHeaders: Record<string, string>,
logger: ListingLogger,
): Promise<string[] | undefined> {
const ids = await listPages(
`${baseUrl.replace(/\/+$/, "")}/foundation-models`,
{},
listingHeaders(providerHeaders, {}),
logger,
(body) => {
const page = body as { modelSummaries?: unknown };
if (!Array.isArray(page.modelSummaries)) return undefined;
return { ids: stringField(page.modelSummaries, "modelId") };
},
);
return ids?.filter((id) => id.includes("anthropic."));
}
/** The cross-region prefix of a Bedrock model id or ARN, if it has one. */
export function bedrockRegionPrefix(modelId: string): string | undefined {
const id = modelId.slice(modelId.lastIndexOf("/") + 1).toLowerCase();
return BEDROCK_REGION_PREFIXES.find((prefix) => id.startsWith(`${prefix}.anthropic.`));
}
/**
* Picks the region of the listed Bedrock models: `global` when the gateway
* lists it, else the prefix of the first id that has one. A gateway that lists
* `eu.*` and `us.*` ids gets the prefix that comes first, and the ids of the
* other prefixes are dropped, so the picker offers only models the CLI can
* call under that prefix. Ids without a prefix (`anthropic.claude-…`) are
* dropped too once a prefix is picked; without any prefix every id is kept.
*/
export function selectBedrockRegion(modelIds: readonly string[]): {
regionPrefix?: string;
modelIds: string[];
} {
const prefixes = modelIds.map(bedrockRegionPrefix);
const regionPrefix = prefixes.includes("global")
? "global"
: prefixes.find((prefix) => prefix !== undefined);
if (!regionPrefix) return { modelIds: [...modelIds] };
return {
regionPrefix,
modelIds: modelIds.filter((id) => bedrockRegionPrefix(id) === regionPrefix),
};
}
// Provider headers win over the defaults, matched case-insensitively, the way
// `ANTHROPIC_CUSTOM_HEADERS` overrides the CLI's own headers.
function listingHeaders(
providerHeaders: Record<string, string>,
protocolHeaders: Record<string, string>,
): Headers {
const headers = new Headers({
authorization: `Bearer ${PROVIDER_AUTH_TOKEN_PLACEHOLDER}`,
accept: "application/json",
...protocolHeaders,
});
for (const [name, value] of Object.entries(providerHeaders)) {
// `Headers.set` puts an invalid value into its error message, so name only the header.
try {
headers.set(name, value);
} catch {
throw new GatewayListingError(`Model listing cannot send the invalid header "${name}"`);
}
}
return headers;
}
function stringField(entries: unknown[], field: string): string[] {
return entries.flatMap((entry) => {
const value = (entry as Record<string, unknown> | null)?.[field];
return typeof value === "string" && value.trim() !== "" ? [value] : [];
});
}
/**
* Reads `endpoint` page by page; `parse` returns `undefined` for a body it does
* not understand. A 4xx answer on any page means the gateway has no listing:
* the result is `undefined`, and nothing is narrowed.
*/
async function listPages(
endpoint: string,
firstQuery: Record<string, string>,
headers: Headers,
logger: ListingLogger,
parse: (body: unknown) => ListingPage | undefined,
): Promise<string[] | undefined> {
const { pathname: path, username, password } = new URL(endpoint);
const failure = (reason: string): GatewayListingError => {
const error = new GatewayListingError(`Model listing GET ${path} ${reason}`);
logger.error(`[providers/set] ${error.message}`);
return error;
};
// fetch refuses such a URL and repeats it, credentials included, in its error.
if (username || password) throw failure("cannot be sent: the base URL carries credentials");
const signal = AbortSignal.timeout(LISTING_TIMEOUT_MS);
const ids: string[] = [];
let query: Record<string, string> | undefined = firstQuery;
for (let page = 0; page < MAX_LISTING_PAGES && query; page++) {
const url = new URL(endpoint);
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
let response: Response;
let body: string;
try {
response = await fetch(url, { headers, signal });
body = await response.text();
} catch (error) {
throw failure(`failed: ${error instanceof Error ? error.message : String(error)}`);
}
if (response.status >= 400 && response.status < 500) {
logger.log(
`[providers/set] Model listing GET ${path} returned ${response.status}: the gateway has no listing, models are not narrowed`,
);
return undefined;
}
if (!response.ok) {
throw failure(`returned ${response.status}: ${body.slice(0, MAX_LOGGED_BODY_CHARS)}`);
}
let parsed: ListingPage | undefined;
try {
parsed = parse(JSON.parse(body));
} catch {
parsed = undefined;
}
if (!parsed) throw failure("returned an unexpected body");
ids.push(...parsed.ids);
query = parsed.nextQuery;
}
if (query) {
logger.error(
`[providers/set] Model listing GET ${path} still had more pages after ${MAX_LISTING_PAGES}; using what was read`,
);
}
return ids;
}