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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ Your proxy must advertise the exact ID for a model to be available.
| Google models | `gemini-2.5-flash-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`, `gemini-3.1-flash-lite-image` |
| xAI models | `grok-imagine-image`, `grok-imagine-image-quality`, `grok-imagine-image-2.0`, `grok-imagine-video`, `grok-imagine-video-1.5`, `grok-imagine-video-1.5-preview` |

The listed Gemini image models also support advertised `vertex/` and `antigravity/` routes. Those prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, media aliases, unknown media IDs, Google Veo, and editing endpoints are unsupported.
Supported media IDs can also use advertised `api/` and `oauth/` credential routes. Gemini image models also support `vertex/` and `antigravity/` backend routes. Those backend prefixes do not enable OpenAI or xAI media execution; the picker shows those entries as disabled. Other prefixes, nested prefixes, media aliases, unknown media IDs, Google Veo, and editing endpoints are unsupported.

The extension disables these retired Imagen IDs, including their `vertex/` and `antigravity/` forms:
The extension disables these retired Imagen IDs, including their supported prefixed forms:

- `imagen-3.0-generate-002`
- `imagen-3.0-fast-generate-001`
Expand Down Expand Up @@ -199,6 +199,8 @@ Invalid management credentials, rejected account access, invalid usage data, or

Management requests stay on the configured proxy and reject redirects. Account access tokens stay in CLIProxyAPI. The extension reads account metadata and proxies usage GETs; it does not reset quotas, change credentials, or clear proxy cooldowns. Management keys grant broader access than chat keys, so keep them private. Live lookup supports up to 100 active Claude subscription accounts.

Explicit `api/` routes skip live Claude subscription lookup, even with a management key. `oauth/` routes keep the lookup for matching OAuth accounts. Retries keep the selected route's full model ID.

Other chat routes retain the response-based fallback: `Retry-After` on HTTP 429 or a recognized quota HTTP 403, or `reset_seconds` in a `model_cooldown` error. Google's native adapter exposes only the structured error body. Those waits share the same seven-day deadline but cannot inspect live weekly usage. Image and video generation still do not retry.

### Compaction
Expand All @@ -225,9 +227,9 @@ To describe a proxy alias, add a canonical metadata reference to `~/.pi/agent/pi

Replace `<canonical-model-id>` with an ID from Pi's Anthropic catalog. References can also use `openai/`, `openai-codex/`, or `google/`. Aliases select metadata; requests retain the original proxy ID.

A `vertex/` or `antigravity/` route can reuse its unprefixed ID's alias if that reference resolves to one metadata entry. An alias for the full prefixed ID takes precedence. Custom prefixes need an explicit chat alias. Arbitrary aliases may lack ID-specific adapter behavior; canonical Gemini IDs and the two recognized prefixes retain native thinking and tool-turn handling.
The recognized prefixes are `vertex/`, `antigravity/`, `api/`, and `oauth/`. Each can reuse its unprefixed ID's alias if that reference resolves to one metadata entry. An alias for the full prefixed ID takes precedence. Other prefixes need an explicit chat alias. Arbitrary aliases may lack ID-specific adapter behavior; canonical Gemini IDs and the recognized prefixes retain native thinking and tool-turn handling.

For Antigravity IDs `claude-opus-5-5-high` and `claude-sonnet-5-5-high`, the extension falls back to `anthropic/claude-opus-5-5` and `anthropic/claude-sonnet-5-5` metadata. This applies to bare IDs and recognized `antigravity/` or `vertex/` routes. Explicit aliases take precedence, followed by exact native metadata. The fallback requires the corresponding canonical entry in Pi and names the model like a configured alias, for example `claude-opus-5-5-high (Claude Opus 5.5)`; requests keep the advertised `-high` ID.
For Antigravity IDs `claude-opus-5-5-high` and `claude-sonnet-5-5-high`, the extension falls back to `anthropic/claude-opus-5-5` and `anthropic/claude-sonnet-5-5` metadata. This applies to bare IDs and the recognized route prefixes. Explicit aliases take precedence, followed by exact native metadata. The fallback requires the corresponding canonical entry in Pi and names the model like a configured alias, for example `claude-opus-5-5-high (Claude Opus 5.5)`; requests keep the advertised `-high` ID.

Use Pi's `models.json` `modelOverrides` for limits, prices, or compatibility changes. Use its `models` array for IDs with no built-in metadata. Updating Pi supplies newer metadata. Catalog prices are estimates, not the proxy's bill; check upstream limits before increasing them.

Expand All @@ -244,6 +246,27 @@ These examples select media defaults. Chat routes belong in Pi's built-in `/mode

Set the CLIProxyAPI auth record's top-level `prefix` to `vertex` or `antigravity` to advertise those routes. With `routing.force-model-prefix: false` in CLIProxyAPI 8, the proxy retains bare IDs too. Legacy configurations use the top-level `force-model-prefix` setting. This extension does not change proxy configuration or restart it.

## OAuth and API-key routes

CLIProxyAPI can advertise separate model IDs for OAuth and API-key credentials. The extension labels `oauth/<model>` as **OAuth** and `api/<model>` as **API key**. It matches the unprefixed model's metadata and sends the full advertised ID on requests. It does not retry an explicit route as a bare ID or another credential route.

Configure the prefixes in CLIProxyAPI's v8 layout:

- For an API-key group, set `api-keys.<provider>[].prefix` to `api`. A key-level `prefix` can override the group value.
- For an OAuth credential, set the auth record's top-level `prefix` to `oauth`.

If a legacy file stores client keys in the root `api-keys` list, migrate them to `access.api-keys` before adding provider groups. A successful v8 management write performs this migration.

CLIProxyAPI also supports per-group and per-key model aliases, exclusions, priorities, and proxy overrides. See its [configuration fields](https://github.com/router-for-me/cliproxyapidocs/blob/main/docs/en/configuration/options.md).

The [v8 management API](https://github.com/router-for-me/cliproxyapidocs/blob/main/docs/en/management/apiv8.md) manages API-key groups through `/v8/management/config/api-keys/<provider>`. These lists must be replaced as a whole; numeric paths cannot select an entry. OAuth fields use `PATCH /v8/management/credentials/fields`. The extension does not call these endpoints or need a management key for model discovery.

After setting the prefixes, run `/cliproxyapi-refresh`. Choose `cliproxyapi/oauth/<model>` or `cliproxyapi/api/<model>` in Pi's `/model` picker. Media routes appear in `/cli:model`.

The extension trusts your prefix configuration; it does not inspect credential types. Keep each prefix limited to its matching credential type. Unprefixed IDs remain **Automatic (proxy routing)** and may use either pool. Set `routing.force-model-prefix: true` in CLIProxyAPI to exclude prefixed credentials from unprefixed requests.

Chat and media selections remain independent. The automatic OpenAI image default is still unprefixed; select a prefixed media model to choose its credential route.

## Thinking and child sessions

Use a reference such as `cliproxyapi/<model-id>:high` in Pi or pi-subagents. Pi separates the thinking suffix from the request ID. Supported levels come from model metadata; `xhigh` and `max` require explicit support. Pi clamps unsupported levels, and models that require thinking cannot use `off`. Keep thinking suffixes out of alias references.
Expand Down
17 changes: 11 additions & 6 deletions src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@ export function parseCatalog(value: unknown) {

// Resolve metadata and capabilities without changing registry or request IDs.
export function modelRoute(id: string) {
const match = /^(vertex|antigravity)\/(.+)$/.exec(id);
const match = /^(vertex|antigravity|oauth|api)\/(.+)$/.exec(id);
const prefix = match?.[1];
return {
metadataId: match?.[2] ?? id,
backend: match ? (match[1] === "vertex" ? "Vertex" : "Antigravity") : undefined,
prefix,
backend: prefix === "vertex" ? "Vertex" : prefix === "antigravity" ? "Antigravity" : undefined,
authType: prefix === "api" ? "api-key" : prefix === "oauth" ? "oauth" : undefined,
};
}

export function backendLabel(id: string) {
return modelRoute(id).backend ?? (id.includes("/") ? "Unknown backend" : "Automatic (proxy routing)");
const { backend, authType } = modelRoute(id);
if (authType) return authType === "api-key" ? "API key" : "OAuth";
return backend ?? (id.includes("/") ? "Unknown backend" : "Automatic (proxy routing)");
}

export function routedName(id: string, name = id) {
Expand Down Expand Up @@ -188,11 +193,11 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model
skipped.add(entry.id);
continue;
}
const { metadataId, backend } = modelRoute(entry.id);
const routed = !entry.id.includes("/") || backend;
const { metadataId, prefix } = modelRoute(entry.id);
const routed = !entry.id.includes("/") || prefix;
const alias = Object.hasOwn(config.aliases, entry.id)
? config.aliases[entry.id]
: backend && Object.hasOwn(config.aliases, metadataId)
: prefix && Object.hasOwn(config.aliases, metadataId)
? config.aliases[metadataId]
: routed && !byId.has(metadataId)
? metadataFallbacks.get(metadataId)
Expand Down
5 changes: 4 additions & 1 deletion src/claude-quota.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { setTimeout as sleep } from "node:timers/promises";
import { modelRoute } from "./catalog.ts";
import { type Config, isRecord } from "./config.ts";

export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void;
Expand Down Expand Up @@ -150,7 +151,9 @@ export async function fetchClaudeQuota(
// ponytail: probe at most 100 accounts; batch larger pools before raising this limit.
if (accounts.length > 100)
throw new Error("Live quota lookup supports at most 100 active Claude accounts.");
const reference = config.aliases[modelId] ?? modelId;
const { metadataId, prefix } = modelRoute(modelId);
const reference =
config.aliases[modelId] ?? (prefix ? config.aliases[metadataId] : undefined) ?? metadataId;
const results = await Promise.allSettled(
accounts.map(async (account: unknown) => {
if (
Expand Down
4 changes: 2 additions & 2 deletions src/media-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ export function readMediaDefaults(config: Config, ctx: DefaultsContext) {
export function automaticImageDefault(config: Config, ctx: DefaultsContext) {
const model = ctx.model;
if (!model || mediaPurpose(model.id)) return undefined;
const { metadataId, backend } = modelRoute(model.id);
const { metadataId, prefix } = modelRoute(model.id);
const alias =
model.provider === PROVIDER_ID
? Object.hasOwn(config.aliases, model.id)
? config.aliases[model.id]
: backend && Object.hasOwn(config.aliases, metadataId)
: prefix && Object.hasOwn(config.aliases, metadataId)
? config.aliases[metadataId]
: undefined
: undefined;
Expand Down
9 changes: 5 additions & 4 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,12 @@ function route(
(selected, context, options) => {
// A resumed selection must not send the current key to an old endpoint.
const model = { ...selected, baseUrl: endpoint(config.baseUrl, selected.api) };
const { metadataId, backend } = modelRoute(model.id);
const google = backend && model.api === "google-generative-ai";
const { metadataId, prefix, backend, authType } = modelRoute(model.id);
const google = prefix && model.api === "google-generative-ai";
const managementKey =
options?.env?.CLIPROXYAPI_MANAGEMENT_KEY ?? process.env.CLIPROXYAPI_MANAGEMENT_KEY ?? "";
const liveClaude = model.api === "anthropic-messages" && !backend && managementKey !== "";
const liveClaude =
model.api === "anthropic-messages" && !backend && authType !== "api-key" && managementKey !== "";
const adapterModel = google ? { ...model, id: metadataId } : model;
// Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route.
const adapterContext = google
Expand Down Expand Up @@ -350,7 +351,7 @@ export function createCliproxyProvider(
) {
const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]);
const scope = createHash("sha256")
.update(JSON.stringify([4, { baseUrl: config.baseUrl, aliases: config.aliases }]))
.update(JSON.stringify([5, { baseUrl: config.baseUrl, aliases: config.aliases }]))
.digest("hex");
const provider = createProvider<CpaApi>({
id: PROVIDER_ID,
Expand Down
84 changes: 84 additions & 0 deletions test/auth-routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mapCatalog, mapMediaCatalog, mediaCapability, mediaControls } from "../src/catalog.ts";
import { PROVIDER_ID, parseConfig } from "../src/config.ts";
import { automaticImageDefault, OPENAI_IMAGE_DEFAULT } from "../src/media-defaults.ts";
import { pickerCatalog, searchPickerItems } from "../src/picker.ts";
import { builtinCatalog } from "../src/provider.ts";

const mediaIds = ["gpt-image-1.5", "gemini-3-pro-image", "grok-imagine-image", "grok-imagine-video"];

test("auth-prefixed media retains its execution route, controls and searchable labels", () => {
for (const prefix of ["api", "oauth"]) {
const ids = mediaIds.map((id) => `${prefix}/${id}`);
const catalog = { data: ids.map((id) => ({ id })) };
const models = mapMediaCatalog(catalog);
assert.deepEqual(
models.map((model) => model.id),
ids,
);
for (const [index, id] of ids.entries()) {
assert.equal(mediaCapability(id)?.route, mediaCapability(mediaIds[index])?.route);
assert.deepEqual(mediaControls(id), mediaControls(mediaIds[index]));
assert.match(models[index].name, prefix === "api" ? / · API key$/ : / · OAuth$/);
}
const rows = pickerCatalog(catalog);
assert.equal(rows.length, ids.length);
assert.ok(rows.every((row) => row.supported));
assert.deepEqual(
searchPickerItems(
rows.map((row) => ({
value: row.id,
label: row.name,
description: row.backend,
supported: row.supported,
})),
prefix === "api" ? "API key" : "OAuth",
).map((row) => row.value),
ids,
);
}
});

test("auth prefixes preserve hidden, retired and unknown media exclusions", () => {
for (const prefix of ["api", "oauth"]) {
const retired = `${prefix}/imagen-4.0-generate-001`;
const unknown = `${prefix}/future-image`;
const hidden = `${prefix}/gpt-image-1.5`;
const catalog = { data: [{ id: retired }, { id: unknown }, { id: hidden, visibility: "hide" }] };
assert.deepEqual(mapMediaCatalog(catalog), []);
const rows = pickerCatalog(catalog);
assert.equal(rows.length, 1);
assert.equal(rows[0].id, retired);
assert.equal(rows[0].supported, false);
assert.match(rows[0].disabledReason ?? "", /Retired/);
}
for (const id of ["custom/gpt-image-1.5", "API/gpt-image-1.5", "api/custom/gpt-image-1.5"])
assert.equal(mediaCapability(id), undefined);
assert.match(mediaCapability("vertex/gpt-image-1.5")?.disabledReason ?? "", /Unsupported/);
});

test("auth-prefixed media cannot become chat through metadata aliases", () => {
const known = builtinCatalog();
const reference = known.find((model) => model.provider === "openai");
assert.ok(reference);
const ids = ["api/gpt-image-1.5", "oauth/grok-imagine-video"];
const config = parseConfig({
aliases: Object.fromEntries(ids.map((id) => [id, `openai/${reference.id}`])),
});
assert.deepEqual(mapCatalog({ data: ids.map((id) => ({ id })) }, config, known).models, []);
});

test("automatic media selection remains separate from the chat credential route", () => {
const reference = builtinCatalog().find((model) => model.provider === "openai");
assert.ok(reference);
for (const prefix of ["api", "oauth"]) {
const model: typeof reference = { ...reference, provider: PROVIDER_ID, id: `${prefix}/gpt-fixture` };
assert.equal(automaticImageDefault(parseConfig({}), { model }), OPENAI_IMAGE_DEFAULT);
const config = parseConfig({ aliases: { "team-chat": "openai/gpt-fixture" } });
const aliasModel: typeof reference = { ...model, id: `${prefix}/team-chat` };
assert.equal(automaticImageDefault(config, { model: aliasModel }), OPENAI_IMAGE_DEFAULT);
config.aliases[aliasModel.id] = "anthropic/claude-fixture";
assert.equal(automaticImageDefault(config, { model: aliasModel }), undefined);
}
});
Loading
Loading